haixunMaster/lib/ai/generate-matrix.ts

244 lines
8.0 KiB
TypeScript
Raw 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";
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";
import type { MatrixRow, ResearchMap } from "@/lib/types/research";
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(),
})
),
});
/** 非 OpenCode Go 但 structured output 不穩的模型 */
const TEXT_FIRST_MODELS = new Set(["grok-3", "grok-3-fast"]);
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 }>;
}>;
}
function buildSystemPrompt(persona?: string | null) {
return withAgentSystem(`你是 Threads 內容企劃師。根據篩選後的優質素材,產出「內容矩陣」——一週可發的貼文企劃表。
${buildPersonaPromptBlock(persona)}
-
- hook
- text Threads 500 #
${HASHTAG_WRITING_RULES}
- referenceNotes 2-3
- searchTag
- sortOrder 1
-
- rationale `);
}
function buildUserPrompt(input: GenerateMatrixInput, researchBlock: string, materials: string) {
return `主題:${sanitizePromptText(input.topicLabel)}
${sanitizePromptText(input.query)}
${input.brief ? `Brief${sanitizePromptText(input.brief)}` : ""}
${researchBlock}
${materials}
${input.count}
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),
}));
}
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);
}
export async function generateContentMatrix(input: GenerateMatrixInput): Promise<MatrixRow[]> {
const model = getModel(input.aiProvider, input.aiModel, input.apiKeys ?? {});
const researchBlock = input.researchMap
? `
${sanitizePromptText(input.researchMap.audienceSummary)}
${sanitizePromptText(input.researchMap.contentGoal)}
${input.researchMap.questions.map(sanitizePromptText).join("、")}
${input.researchMap.pillars.map(sanitizePromptText).join("、")}
`
: "";
const materials = input.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(input.query)}] @${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");
const system = buildSystemPrompt(input.persona);
const prompt = buildUserPrompt(input, researchBlock, materials);
const fallback = { query: input.query, count: input.count };
const preferText =
prefersOpenCodeTextFirst(input.aiProvider, input.aiModel) ||
TEXT_FIRST_MODELS.has(input.aiModel);
let rows: MatrixRow[] | null = null;
let lastError: unknown;
const attempts: Array<() => Promise<MatrixRow[]>> = preferText
? [
() => generateWithText(model, system, prompt, fallback, input.aiProvider, input.aiModel),
() => generateWithObject(model, system, prompt, fallback, input.aiProvider, input.aiModel),
() =>
generateWithText(
model,
system,
`${prompt}\n\n上次格式不完整請務必補齊 ${input.count} 篇 rows。`,
fallback,
input.aiProvider,
input.aiModel
),
]
: [
() => generateWithObject(model, system, prompt, fallback, input.aiProvider, input.aiModel),
() => generateWithText(model, system, prompt, fallback, input.aiProvider, input.aiModel),
() =>
generateWithText(
model,
system,
`${prompt}\n\n上次格式不完整請務必補齊 ${input.count} 篇 rows。`,
fallback,
input.aiProvider,
input.aiModel
),
];
for (const attempt of attempts) {
try {
rows = await attempt();
break;
} catch (error) {
lastError = error;
}
}
if (!rows) {
throw new Error(formatMatrixError(lastError));
}
return normalizeRows(rows);
}