haixunMaster/lib/ai/generate-matrix.ts

315 lines
10 KiB
TypeScript
Raw Permalink Normal View History

2026-06-21 12:50:31 +00:00
import { generateObject, generateText } from "ai";
import { z } from "zod";
import type { ProviderApiKeys } from "./keys";
2026-06-21 16:28:26 +00:00
import type { MatrixRow, ResearchMap } from "@/lib/types/research";
2026-06-21 12:50:31 +00:00
import { withAgentSystem } from "./agent";
import { buildPersonaPromptBlock } from "./persona";
import { getModel } from "./provider";
import { extractJsonFromText } from "./coerce-research-map";
import {
getOpenCodeGenerationSettings,
prefersOpenCodeTextFirst,
} from "./opencode-go-settings";
import {
coerceMatrixRaw,
formatMatrixError,
matrixLooseSchema,
} from "./coerce-matrix";
import { HASHTAG_USER_REMINDER, HASHTAG_WRITING_RULES } from "./hashtag-rules";
import { sanitizePromptText, THREADS_MAX_CHARS } from "@/lib/utils";
const matrixSchema = z.object({
rows: z.array(
z.object({
sortOrder: z.number().int().min(1),
searchTag: z.string(),
angle: z.string(),
hook: z.string(),
text: z.string().max(THREADS_MAX_CHARS),
referenceNotes: z.string(),
sourcePermalinks: z.array(z.string()),
rationale: z.string(),
})
),
});
const TEXT_FIRST_MODELS = new Set(["grok-3", "grok-3-fast"]);
2026-06-21 16:28:26 +00:00
const BATCH_COUNT = 3;
2026-06-21 12:50:31 +00:00
export interface GenerateMatrixInput {
topicLabel: string;
query: string;
brief?: string | null;
persona?: string | null;
researchMap?: ResearchMap | null;
aiProvider: string;
aiModel: string;
apiKeys?: ProviderApiKeys;
count: number;
posts: Array<{
text: string;
authorName?: string | null;
permalink?: string | null;
searchTag?: string | null;
likeCount?: number | null;
replyCount?: number | null;
qualityReason?: string | null;
replies?: Array<{ text: string; authorName?: string | null; likeCount?: number | null }>;
}>;
}
2026-06-21 16:28:26 +00:00
function distributePosts<T>(posts: T[], n: number): T[][] {
const batches: T[][] = Array.from({ length: n }, () => []);
posts.forEach((post, i) => batches[i % n].push(post));
return batches;
}
function buildMaterialsBlock(posts: GenerateMatrixInput["posts"], defaultQuery: string): string {
return posts
.map((post, i) => {
const repliesBlock =
post.replies && post.replies.length > 0
? `\n 留言:${post.replies
.slice(0, 3)
.map(
(r) =>
`@${sanitizePromptText(r.authorName) || "匿名"}${sanitizePromptText(r.text).slice(0, 80)}`
)
.join(" | ")}`
: "";
return `${i + 1}. [${sanitizePromptText(post.searchTag) || sanitizePromptText(defaultQuery)}] @${sanitizePromptText(post.authorName) || "匿名"}${post.likeCount ?? 0}讚)
${sanitizePromptText(post.text).slice(0, 300)}
${sanitizePromptText(post.permalink) || "無"}${repliesBlock}${post.qualityReason ? `\n 品質說明:${sanitizePromptText(post.qualityReason)}` : ""}`;
})
.join("\n\n");
}
function buildResearchBlock(researchMap?: ResearchMap | null): string {
if (!researchMap) return "";
const similarAccounts = (researchMap.similarAccounts ?? [])
.slice(0, 10)
.map((a) => ` @${sanitizePromptText(a.username)}${a.reason ? `${sanitizePromptText(a.reason)}` : ""}`)
.join("\n");
return `
${sanitizePromptText(researchMap.audienceSummary)}
${sanitizePromptText(researchMap.contentGoal)}
${researchMap.questions.map(sanitizePromptText).join("、")}
${researchMap.pillars.map(sanitizePromptText).join("、")}
${similarAccounts ? `同領域參考帳號:\n${similarAccounts}` : ""}
`;
}
2026-06-21 12:50:31 +00:00
function buildSystemPrompt(persona?: string | null) {
2026-06-21 16:28:26 +00:00
return withAgentSystem(`你是 Threads 內容企劃師。根據參考素材(含優質與中等品質),產出「內容矩陣」——一週可發的貼文企劃表。
2026-06-21 12:50:31 +00:00
${buildPersonaPromptBlock(persona)}
-
- hook
- text Threads 500 #
${HASHTAG_WRITING_RULES}
- referenceNotes 2-3
- searchTag
- sortOrder 1
-
2026-06-21 16:28:26 +00:00
- rationale
- 沿`);
2026-06-21 12:50:31 +00:00
}
2026-06-21 16:28:26 +00:00
function buildBatchPrompt(
input: GenerateMatrixInput,
researchBlock: string,
materials: string,
batchIndex: number,
totalBatches: number,
thisBatchCount: number
) {
2026-06-21 12:50:31 +00:00
return `主題:${sanitizePromptText(input.topicLabel)}
${sanitizePromptText(input.query)}
${input.brief ? `Brief${sanitizePromptText(input.brief)}` : ""}
${researchBlock}
2026-06-21 16:28:26 +00:00
${batchIndex + 1}/${totalBatches}
${batchIndex + 1} ${materials.split("\n\n").length} ${thisBatchCount}
2026-06-21 12:50:31 +00:00
${materials}
text ${HASHTAG_USER_REMINDER}`;
}
function buildJsonPromptSuffix(count: number) {
return `
JSON markdown
{
"rows": [
{
"sortOrder": 1,
"searchTag": "標籤",
"angle": "切角說明",
"hook": "開頭一句",
"text": "完整貼文(結尾含 13 個 #話題標籤)",
"referenceNotes": "參考重點(多行以換行分隔)",
"sourcePermalinks": ["https://..."],
"rationale": "為什麼這篇有效"
}
]
}
${count} sortOrder 1 text ${THREADS_MAX_CHARS} `;
}
function normalizeRows(rows: MatrixRow[]): MatrixRow[] {
return rows
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((row) => ({
...row,
text: row.text.slice(0, THREADS_MAX_CHARS),
}));
}
2026-06-21 16:28:26 +00:00
function mergeBatches(batches: MatrixRow[][]): MatrixRow[] {
const all = batches.flat();
const seen = new Set<string>();
const merged: MatrixRow[] = [];
for (const row of all.sort((a, b) => a.sortOrder - b.sortOrder)) {
const key = row.angle.slice(0, 30).toLowerCase().replace(/\s+/g, "");
if (!seen.has(key)) {
seen.add(key);
merged.push(row);
}
}
return merged.map((row, i) => ({ ...row, sortOrder: i + 1 }));
}
2026-06-21 12:50:31 +00:00
async function generateWithText(
model: ReturnType<typeof getModel>,
system: string,
prompt: string,
fallback: { query: string; count: number },
provider: string,
modelId: string
) {
const settings = getOpenCodeGenerationSettings(provider, modelId);
const { text } = await generateText({
model,
system: sanitizePromptText(`${system}${buildJsonPromptSuffix(fallback.count)}`),
prompt: sanitizePromptText(prompt),
...settings,
});
const parsed = extractJsonFromText(text);
matrixLooseSchema.parse(parsed);
return coerceMatrixRaw(parsed, fallback);
}
async function generateWithObject(
model: ReturnType<typeof getModel>,
system: string,
prompt: string,
fallback: { query: string; count: number },
provider: string,
modelId: string
) {
const settings = getOpenCodeGenerationSettings(provider, modelId);
const { object } = await generateObject({
model,
schema: matrixSchema,
system: sanitizePromptText(system),
prompt: sanitizePromptText(`${prompt}${buildJsonPromptSuffix(fallback.count)}`),
...settings,
});
return normalizeRows(object.rows);
}
2026-06-21 16:28:26 +00:00
async function attemptBatch(
model: ReturnType<typeof getModel>,
system: string,
prompt: string,
fallback: { query: string; count: number },
provider: string,
modelId: string,
preferText: boolean
): Promise<MatrixRow[]> {
2026-06-21 12:50:31 +00:00
const attempts: Array<() => Promise<MatrixRow[]>> = preferText
? [
2026-06-21 16:28:26 +00:00
() => generateWithText(model, system, prompt, fallback, provider, modelId),
() => generateWithObject(model, system, prompt, fallback, provider, modelId),
2026-06-21 12:50:31 +00:00
() =>
generateWithText(
model,
system,
2026-06-21 16:28:26 +00:00
`${prompt}\n\n上次格式不完整請務必補齊 ${fallback.count} 篇 rows。`,
2026-06-21 12:50:31 +00:00
fallback,
2026-06-21 16:28:26 +00:00
provider,
modelId
2026-06-21 12:50:31 +00:00
),
]
: [
2026-06-21 16:28:26 +00:00
() => generateWithObject(model, system, prompt, fallback, provider, modelId),
() => generateWithText(model, system, prompt, fallback, provider, modelId),
2026-06-21 12:50:31 +00:00
() =>
generateWithText(
model,
system,
2026-06-21 16:28:26 +00:00
`${prompt}\n\n上次格式不完整請務必補齊 ${fallback.count} 篇 rows。`,
2026-06-21 12:50:31 +00:00
fallback,
2026-06-21 16:28:26 +00:00
provider,
modelId
2026-06-21 12:50:31 +00:00
),
];
2026-06-21 16:28:26 +00:00
let rows: MatrixRow[] | null = null;
let lastError: unknown;
2026-06-21 12:50:31 +00:00
for (const attempt of attempts) {
try {
rows = await attempt();
break;
} catch (error) {
lastError = error;
}
}
if (!rows) {
throw new Error(formatMatrixError(lastError));
}
return normalizeRows(rows);
2026-06-21 16:28:26 +00:00
}
export async function generateContentMatrix(input: GenerateMatrixInput): Promise<MatrixRow[]> {
if (input.posts.length === 0) {
throw new Error("此海巡沒有素材,請重新海巡");
}
const model = getModel(input.aiProvider, input.aiModel, input.apiKeys ?? {});
const researchBlock = buildResearchBlock(input.researchMap);
const system = buildSystemPrompt(input.persona);
const preferText =
prefersOpenCodeTextFirst(input.aiProvider, input.aiModel) ||
TEXT_FIRST_MODELS.has(input.aiModel);
const batches = distributePosts(input.posts, BATCH_COUNT);
const perBatch = Math.ceil(input.count / BATCH_COUNT);
const results = await Promise.all(
batches.map((batchPosts, batchIndex) => {
if (batchPosts.length === 0) return Promise.resolve([] as MatrixRow[]);
const materials = buildMaterialsBlock(batchPosts, input.query);
const prompt = buildBatchPrompt(input, researchBlock, materials, batchIndex, BATCH_COUNT, perBatch);
const fallback = { query: input.query, count: perBatch };
return attemptBatch(model, system, prompt, fallback, input.aiProvider, input.aiModel, preferText);
})
);
const merged = mergeBatches(results);
return normalizeRows(merged).slice(0, input.count);
}