thread-master/apps/web/src/lib/personaPrompt.ts

277 lines
8.7 KiB
TypeScript
Raw 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 type {
Persona,
PersonaDraftFields,
PersonaGuard,
PersonaStyle,
StyleDimKey,
StyleDimension,
} from "../domain/types";
import { nowUnixNano } from "./time";
export const DIM_ORDER: StyleDimKey[] = [
"d1Tone",
"d2Structure",
"d3Interaction",
"d4Topics",
"d5Rhythm",
"d6Visual",
"d7Conversion",
"d8Risk",
];
export const DIM_LABELS: Record<StyleDimKey, string> = {
d1Tone: "D1 語氣人格",
d2Structure: "D2 結構模板",
d3Interaction: "D3 互動方式",
d4Topics: "D4 主題分布",
d5Rhythm: "D5 發文節奏",
d6Visual: "D6 視覺語法",
d7Conversion: "D7 轉換方式",
d8Risk: "D8 風險紅線",
};
export type PersonaPromptMode = "post" | "reply" | "outreach" | "inspire";
export function emptyDraftFields(): PersonaDraftFields {
return {
identity: "",
tone: "",
audience: "",
hooks: "",
languageFingerprint: "",
rhythm: "",
punctuation: "",
contentPatterns: "",
knowledgeTranslation: "",
ctaStyle: "",
examples: "",
avoid: "",
};
}
export function emptyGuard(): PersonaGuard {
return {
avoid: ["硬廣", "客服腔", "條列說教"],
maxChars: 280,
banAiTone: true,
};
}
export function emptyStyle(): PersonaStyle {
return {
dimensions: {},
draft: emptyDraftFields(),
draftText: "",
source: "manual",
sampleCount: 0,
};
}
export function createEmptyPersona(partial?: Partial<Persona> & { id: string; name: string }): Persona {
return {
id: partial?.id || "",
name: partial?.name || "",
brief: partial?.brief || "",
status: partial?.status || "empty",
style: partial?.style || emptyStyle(),
guard: partial?.guard || emptyGuard(),
voice: partial?.voice,
notes: partial?.notes,
};
}
/** 把舊 name/voice/notes 人設升級成三層結構 */
export function normalizePersona(raw: Persona | Record<string, unknown>): Persona {
const r = raw as Partial<Persona> & { voice?: string; notes?: string };
const draft = {
...emptyDraftFields(),
...(r.style?.draft || {}),
};
if (!draft.tone && r.voice) draft.tone = r.voice;
if (!draft.identity && r.name) draft.identity = r.name;
if (!draft.avoid && r.notes) draft.avoid = r.notes;
const style: PersonaStyle = {
dimensions: r.style?.dimensions || {},
draft,
draftText: r.style?.draftText || (r.style ? serializeDraftText(draft) : ""),
source: r.style?.source || "manual",
sampleCount: r.style?.sampleCount || 0,
analyzedAt: r.style?.analyzedAt,
};
if (!style.draftText && (draft.tone || draft.identity)) {
style.draftText = serializeDraftText(draft);
}
const hasFingerprint = Boolean(style.draftText.trim()) || Object.keys(style.dimensions).length > 0;
const status = r.status || (hasFingerprint ? "ready" : "empty");
return {
id: String(r.id || ""),
name: String(r.name || "未命名人設"),
brief: String(r.brief || r.notes || ""),
status,
style,
guard: {
...emptyGuard(),
...(r.guard || {}),
avoid: r.guard?.avoid?.length
? r.guard.avoid
: emptyGuard().avoid,
},
voice: r.voice || draft.tone,
notes: r.notes || r.brief,
};
}
export function serializeDraftText(d: PersonaDraftFields): string {
const lines: string[] = [];
const push = (label: string, value: string) => {
const v = value.trim();
if (v) lines.push(`${label}\n${v}`);
};
push("我是誰", d.identity);
push("語氣", d.tone);
push("對誰說", d.audience);
push("開場鉤子", d.hooks);
push("語言指紋", d.languageFingerprint);
push("節奏", d.rhythm);
push("標點", d.punctuation);
push("內容套路", d.contentPatterns);
push("知識轉譯", d.knowledgeTranslation);
push("CTA", d.ctaStyle);
push("像他會說的話", d.examples);
push("絕不怎麼說", d.avoid);
return lines.join("\n\n");
}
export function toneOf(persona?: Persona | null): string {
if (!persona) return "自然口語";
return persona.style?.draft?.tone || persona.voice || "自然口語";
}
export function isPersonaReady(persona?: Persona | null): boolean {
return persona?.status === "ready" && Boolean(persona.style?.draftText?.trim() || persona.style?.draft?.tone);
}
export function personaOptionLabel(persona: Persona): string {
const tone = toneOf(persona);
const badge = persona.status === "ready" ? "ready" : persona.status === "analyzing" ? "分析中" : "待分析";
return `${persona.name} · ${tone}${badge}`;
}
/**
* 依任務切片組裝人設 block。
* mock / live 產文都應只吃這個字串。
*/
export function buildPersonaPromptBlock(persona: Persona | null | undefined, mode: PersonaPromptMode): string {
if (!persona) {
return "【人設】尚未選擇。請用台灣繁體、口語、像真人在 Threads 打字。";
}
const p = normalizePersona(persona);
const parts: string[] = [];
// Layer A — who
if (mode === "post" || mode === "inspire" || mode === "outreach") {
const who = [p.name && `名稱:${p.name}`, p.brief && `定位:${p.brief}`, p.style.draft.identity && `角色:${p.style.draft.identity}`]
.filter(Boolean)
.join("\n");
if (who) parts.push(`【人設定位】\n${who}`);
} else {
// reply: light identity
const light = p.style.draft.identity || p.name;
if (light) parts.push(`【人設】你是「${light}」,用自己的語氣回覆。`);
}
// Layer B — how
const draftText = p.style.draftText.trim() || serializeDraftText(p.style.draft);
if (mode === "post") {
if (draftText) parts.push(`【語言指紋與寫作規則】\n${draftText}`);
const d2 = p.style.dimensions.d2Structure?.summary;
const d4 = p.style.dimensions.d4Topics?.summary;
if (d2 || d4) {
parts.push(
`【結構與主題重點】\n${[d2 && `結構:${d2}`, d4 && `主題:${d4}`].filter(Boolean).join("\n")}`,
);
}
} else if (mode === "reply") {
if (draftText) parts.push(`【語言指紋】\n${draftText}`);
const d3 = p.style.dimensions.d3Interaction?.summary;
if (d3) parts.push(`【互動方式】\n${d3}`);
} else if (mode === "outreach") {
const slim = [
p.style.draft.tone && `語氣:${p.style.draft.tone}`,
p.style.draft.avoid && `避免:${p.style.draft.avoid}`,
p.style.draft.languageFingerprint && `用字:${p.style.draft.languageFingerprint}`,
p.style.draft.ctaStyle && `收尾:${p.style.draft.ctaStyle}`,
]
.filter(Boolean)
.join("\n");
if (slim) parts.push(`【外展語氣】\n${slim}`);
else if (draftText) parts.push(`【語言指紋】\n${draftText}`);
} else {
// inspire
const d4 = p.style.dimensions.d4Topics?.summary;
const hooks = p.style.draft.hooks;
const audience = p.style.draft.audience;
parts.push(
`【靈感方向】\n${[
audience && `對誰:${audience}`,
hooks && `鉤子:${hooks}`,
d4 && `主題:${d4}`,
p.style.draft.tone && `語氣:${p.style.draft.tone}`,
]
.filter(Boolean)
.join("\n") || draftText || p.brief}`,
);
}
// Layer C — guard
const avoid = [
...p.guard.avoid,
...(p.style.draft.avoid ? p.style.draft.avoid.split(/[、,]/).map((s) => s.trim()).filter(Boolean) : []),
];
const uniqAvoid = [...new Set(avoid)];
const guardLines = [
uniqAvoid.length ? `禁止:${uniqAvoid.join("、")}` : "",
p.guard.banAiTone ? "禁止 AI 腔/客服腔/「總而言之」「希望這對您有幫助」" : "",
p.guard.maxChars ? `長度約不超過 ${p.guard.maxChars}` : "",
mode === "reply" ? "你是貼文作者本人在回,不要裝粉絲或官方帳號。" : "",
mode === "outreach" ? "不硬廣;像過來人分享經驗,可輕輕帶觀點。" : "",
].filter(Boolean);
if (guardLines.length) parts.push(`【護欄】\n${guardLines.join("\n")}`);
// Light D1 + D8 always when available for post/reply
if (mode === "post" || mode === "reply") {
const d1 = p.style.dimensions.d1Tone?.summary;
const d8 = p.style.dimensions.d8Risk?.summary;
if (d1 || d8) {
parts.push(
`【語氣與禁忌參考】\n${[d1 && `語氣:${d1}`, d8 && `紅線:${d8}`].filter(Boolean).join("\n")}`,
);
}
}
return parts.join("\n\n") || `【人設】${p.name}${toneOf(p)}`;
}
export function markReady(persona: Persona): Persona {
const draftText = persona.style.draftText.trim() || serializeDraftText(persona.style.draft);
return {
...persona,
status: draftText ? "ready" : "empty",
style: {
...persona.style,
draftText,
analyzedAt: persona.style.analyzedAt || nowUnixNano(),
},
voice: persona.style.draft.tone || persona.voice,
};
}
export function dimSummary(persona: Persona, key: StyleDimKey): StyleDimension | undefined {
return persona.style.dimensions[key];
}