haixunMaster/lib/ai/analyze-style-8d.ts

169 lines
7.6 KiB
TypeScript
Raw Normal View History

2026-06-21 12:50:31 +00:00
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", "範例"]) ?? "依照 D1D6 抽象仿寫"),
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}`,
});
}