169 lines
7.6 KiB
TypeScript
169 lines
7.6 KiB
TypeScript
import { z } from "zod";
|
||
import type { ProviderApiKeys } from "./keys";
|
||
import { generateStructuredObject } from "./generate-structured";
|
||
import { getModel } from "./provider";
|
||
import { withAgentSystem } from "./agent";
|
||
import type { RawPost } from "@/lib/ranking";
|
||
|
||
type UnknownRecord = Record<string, unknown>;
|
||
|
||
function asRecord(value: unknown): UnknownRecord | null {
|
||
return value && typeof value === "object" && !Array.isArray(value)
|
||
? (value as UnknownRecord)
|
||
: null;
|
||
}
|
||
|
||
function firstValue(record: UnknownRecord, keys: string[]): unknown {
|
||
for (const key of keys) {
|
||
if (record[key] !== undefined) return record[key];
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function normalizeDimension(value: unknown) {
|
||
if (typeof value === "string") return { summary: value, evidence: [] };
|
||
const record = asRecord(value);
|
||
if (!record) return undefined;
|
||
const summary = firstValue(record, ["summary", "description", "analysis", "conclusion", "摘要", "結論"]);
|
||
const rawEvidence = firstValue(record, ["evidence", "examples", "quotes", "proof", "證據", "例句"]);
|
||
const evidence = Array.isArray(rawEvidence)
|
||
? rawEvidence.filter((item): item is string => typeof item === "string").slice(0, 4)
|
||
: typeof rawEvidence === "string"
|
||
? [rawEvidence]
|
||
: [];
|
||
return typeof summary === "string" ? { summary, evidence } : undefined;
|
||
}
|
||
|
||
export function normalizeStyle8DOutput(value: unknown): unknown {
|
||
let root = asRecord(value);
|
||
if (!root) return value;
|
||
for (const key of ["analysis", "style8d", "style8D", "result", "data", "output"]) {
|
||
const nested = asRecord(root[key]);
|
||
if (nested && !root.d1Tone && !root.d1) {
|
||
root = { ...root, ...nested };
|
||
}
|
||
}
|
||
|
||
const dimensions = Array.isArray(root.dimensions) ? root.dimensions : [];
|
||
const dimensionAt = (index: number, aliases: string[]) => {
|
||
const direct = firstValue(root!, aliases);
|
||
if (direct !== undefined) return normalizeDimension(direct);
|
||
const row = dimensions[index];
|
||
return normalizeDimension(row);
|
||
};
|
||
|
||
const personaValue = firstValue(root, ["personaDraft", "persona", "persona_draft", "voiceProfile", "人設草稿"]);
|
||
const personaRecord = asRecord(personaValue);
|
||
const personaString = typeof personaValue === "string" ? personaValue : "";
|
||
const personaDraft = {
|
||
identity: String(firstValue(personaRecord ?? {}, ["identity", "role", "我是誰", "定位"]) ?? personaString),
|
||
tone: String(firstValue(personaRecord ?? {}, ["tone", "voice", "語氣"]) ?? "依照 D1 與 D6 執行"),
|
||
audience: String(firstValue(personaRecord ?? {}, ["audience", "targetAudience", "受眾"]) ?? "依帳號目標受眾"),
|
||
hooks: String(firstValue(personaRecord ?? {}, ["hooks", "openings", "開場"]) ?? "依照 D2 執行"),
|
||
examples: String(firstValue(personaRecord ?? {}, ["examples", "sample", "範例"]) ?? "依照 D1–D6 抽象仿寫"),
|
||
avoid: String(firstValue(personaRecord ?? {}, ["avoid", "risks", "避免"]) ?? "依照 D8 執行"),
|
||
};
|
||
|
||
return {
|
||
d1Tone: dimensionAt(0, ["d1Tone", "d1", "D1", "tone", "D1 語氣人格", "語氣人格"]),
|
||
d2Structure: dimensionAt(1, ["d2Structure", "d2", "D2", "structure", "D2 結構模板", "結構模板"]),
|
||
d3Interaction: dimensionAt(2, ["d3Interaction", "d3", "D3", "interaction", "D3 互動方式", "互動方式"]),
|
||
d4Topics: dimensionAt(3, ["d4Topics", "d4", "D4", "topics", "D4 主題分布", "主題分布"]),
|
||
d5Rhythm: dimensionAt(4, ["d5Rhythm", "d5", "D5", "rhythm", "D5 發文節奏", "發文節奏"]),
|
||
d6Visual: dimensionAt(5, ["d6Visual", "d6", "D6", "visual", "D6 視覺語法", "視覺語法"]),
|
||
d7Conversion: dimensionAt(6, ["d7Conversion", "d7", "D7", "conversion", "D7 轉換方式", "轉換方式"]),
|
||
d8Risk: dimensionAt(7, ["d8Risk", "d8", "D8", "risk", "D8 風險紅線", "風險紅線"]),
|
||
personaDraft,
|
||
};
|
||
}
|
||
|
||
const dimension = z.object({ summary: z.string(), evidence: z.array(z.string()).max(4) });
|
||
const style8dSchema = z.object({
|
||
d1Tone: dimension,
|
||
d2Structure: dimension,
|
||
d3Interaction: dimension,
|
||
d4Topics: dimension,
|
||
d5Rhythm: dimension,
|
||
d6Visual: dimension,
|
||
d7Conversion: dimension,
|
||
d8Risk: dimension,
|
||
personaDraft: z.object({
|
||
identity: z.string(),
|
||
tone: z.string(),
|
||
audience: z.string(),
|
||
hooks: z.string(),
|
||
examples: z.string(),
|
||
avoid: z.string(),
|
||
}),
|
||
});
|
||
|
||
export type Style8DAnalysis = z.infer<typeof style8dSchema>;
|
||
|
||
export interface BenchmarkEngagement {
|
||
sampleSize: number;
|
||
measuredPosts: number;
|
||
medianInteractions: number;
|
||
averageInteractions: number;
|
||
postsAboveThreshold: number;
|
||
threshold: number;
|
||
verdict: "strong" | "usable" | "unknown";
|
||
}
|
||
|
||
export function evaluateBenchmarkEngagement(posts: RawPost[], threshold = 10): BenchmarkEngagement {
|
||
const values = posts
|
||
.map((post) => (post.likeCount ?? 0) + (post.replyCount ?? 0) * 2 + (post.repostCount ?? 0) * 1.5)
|
||
.filter((value) => value > 0)
|
||
.sort((a, b) => a - b);
|
||
const median = values.length
|
||
? values.length % 2
|
||
? values[Math.floor(values.length / 2)]
|
||
: (values[values.length / 2 - 1] + values[values.length / 2]) / 2
|
||
: 0;
|
||
const average = values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
|
||
const above = values.filter((value) => value >= threshold).length;
|
||
const verdict = values.length < 3 ? "unknown" : median >= threshold && above >= 3 ? "strong" : "usable";
|
||
return {
|
||
sampleSize: posts.length,
|
||
measuredPosts: values.length,
|
||
medianInteractions: Math.round(median * 10) / 10,
|
||
averageInteractions: Math.round(average * 10) / 10,
|
||
postsAboveThreshold: above,
|
||
threshold,
|
||
verdict,
|
||
};
|
||
}
|
||
|
||
export function serialize8DPersona(draft: Style8DAnalysis["personaDraft"]): string {
|
||
return [
|
||
`【我是誰】\n${draft.identity}`,
|
||
`【語氣】\n${draft.tone}`,
|
||
`【對誰說】\n${draft.audience}`,
|
||
`【開場習慣】\n${draft.hooks}`,
|
||
`【代表句範例】\n${draft.examples}`,
|
||
`【避免】\n${draft.avoid}`,
|
||
].join("\n\n");
|
||
}
|
||
|
||
export async function analyzeStyle8D(input: {
|
||
username: string;
|
||
posts: RawPost[];
|
||
aiProvider: string;
|
||
aiModel: string;
|
||
apiKeys?: ProviderApiKeys;
|
||
}): Promise<Style8DAnalysis> {
|
||
const model = getModel(input.aiProvider, input.aiModel, input.apiKeys ?? {});
|
||
const materials = input.posts.slice(0, 12).map((post, index) =>
|
||
`[${index + 1}] ${post.likeCount ?? 0}讚 ${post.replyCount ?? 0}回覆\n${post.text.slice(0, 500)}`
|
||
).join("\n\n");
|
||
return generateStructuredObject({
|
||
model,
|
||
provider: input.aiProvider,
|
||
modelId: input.aiModel,
|
||
schema: style8dSchema,
|
||
normalize: normalizeStyle8DOutput,
|
||
system: withAgentSystem(`你是 Threads 創作者風格研究員。只能根據提供的近期貼文歸納,不可捏造作者背景。逐一輸出八個維度:D1 語氣人格、D2 結構模板、D3 互動方式、D4 主題分布、D5 發文節奏、D6 視覺語法(emoji、標點、換行)、D7 轉換方式、D8 風險紅線。每個維度要有摘要與可核對的文字證據。最後產生「可供另一個帳號借鑑、但不可冒充或抄襲」的人設草稿。代表句必須是抽象仿寫範例,不可逐字複製原文。`),
|
||
jsonPromptSuffix: `\n\n只回傳以下 JSON 結構,不要 markdown:\n{"d1Tone":{"summary":"","evidence":[]},"d2Structure":{"summary":"","evidence":[]},"d3Interaction":{"summary":"","evidence":[]},"d4Topics":{"summary":"","evidence":[]},"d5Rhythm":{"summary":"","evidence":[]},"d6Visual":{"summary":"","evidence":[]},"d7Conversion":{"summary":"","evidence":[]},"d8Risk":{"summary":"","evidence":[]},"personaDraft":{"identity":"","tone":"","audience":"","hooks":"","examples":"","avoid":""}}`,
|
||
prompt: `對標帳號:@${input.username}\n近期貼文樣本:\n\n${materials}`,
|
||
});
|
||
}
|