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

169 lines
7.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}`,
});
}