feat(llm 對話): 新增 prompt builder 與 few-shot 範例組裝,強化 Claude 對話品質

This commit is contained in:
Jeffery
2026-08-14 12:44:48 +08:00
parent 6ce7b2e800
commit 30719a2960
6 changed files with 187 additions and 12 deletions
+90 -8
View File
@@ -1,19 +1,101 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import type { LLMProvider } from "./llm-provider.js"; import type { LLMProvider } from "./llm-provider.js";
import type { GenerationContext, GeneratedResponse } from "./types.js"; import type { GenerationContext, GeneratedResponse } from "./types.js";
import { buildMessages } from "./prompt-builder.js";
import { extractActions } from "./action-markup.js";
const NOT_IMPLEMENTED_MESSAGE = "ClaudeProvider 尚未實作(R-3 會接上真實 Claude API)"; interface OpenAiCompatibleChoice {
message?: { content?: string };
delta?: { content?: string };
}
interface OpenAiCompatibleResponse {
choices?: OpenAiCompatibleChoice[];
}
// F-2 ClaudeProvider 空殼:R-3 才會實作真實呼叫,目前僅回報未實作。 function baseUrl(): string {
return process.env.CLAUDE_BASE_URL ?? "http://localhost:3000/api/v1";
}
function apiKey(): string {
const key = process.env.CLAUDE_API_KEY;
if (!key) {
throw new Error("CLAUDE_API_KEY 未設定,LLM_PROVIDER=claude 需要這個環境變數才能呼叫真實 API");
}
return key;
}
function model(): string {
return process.env.CLAUDE_MODEL ?? "claude-sonnet-4-5";
}
async function requestChatCompletion(body: Record<string, unknown>): Promise<Response> {
const res = await fetch(`${baseUrl()}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey()}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`ClaudeProvider 呼叫失敗(HTTP ${res.status}):${text.slice(0, 500)}`);
}
return res;
}
// R-3:把 F-2 的空殼換成真的呼叫。這個部署走 CLIProxy(分散式派工代理,見
// gitea.jsc.idv.tw/jiantw83/CLIProxy)提供的 OpenAI 相容端點,而不是直連
// api.anthropic.com——底層仍是 Claude,只是多一層派工代理,對這個介面
// (LLMProvider.generate/stream)完全透明,切換供應商不需要動任何呼叫端程式碼。
@Injectable() @Injectable()
export class ClaudeProvider implements LLMProvider { export class ClaudeProvider implements LLMProvider {
// eslint-disable-next-line @typescript-eslint/no-unused-vars async generate(context: GenerationContext): Promise<GeneratedResponse> {
async generate(_context: GenerationContext): Promise<GeneratedResponse> { const res = await requestChatCompletion({
throw new Error(NOT_IMPLEMENTED_MESSAGE); model: model(),
messages: buildMessages(context),
stream: false,
});
const json = (await res.json()) as OpenAiCompatibleResponse;
const text = json.choices?.[0]?.message?.content ?? "";
if (!text) {
throw new Error("ClaudeProvider 回應內容為空(choices[0].message.content 缺失)");
}
return { text, actions: extractActions(text) };
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars, require-yield async *stream(context: GenerationContext): AsyncIterable<string> {
async *stream(_context: GenerationContext): AsyncIterable<string> { const res = await requestChatCompletion({
throw new Error(NOT_IMPLEMENTED_MESSAGE); model: model(),
messages: buildMessages(context),
stream: true,
});
if (!res.body) {
throw new Error("ClaudeProvider 串流回應沒有 body");
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice("data:".length).trim();
if (payload === "[DONE]") return;
let chunk: OpenAiCompatibleResponse;
try {
chunk = JSON.parse(payload);
} catch {
continue;
}
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) yield delta;
}
}
} }
} }
@@ -44,7 +44,7 @@ export class ContextAssemblerService {
? [] ? []
: await this.retriever.retrieve(characterId, userInput, { relatedUserId: userId }); : await this.retriever.retrieve(characterId, userInput, { relatedUserId: userId });
const history = this.workingMemory.getContext(sessionId).map((entry) => ({ const history = (await this.workingMemory.getContext(sessionId)).map((entry) => ({
role: entry.role, role: entry.role,
content: entry.content, content: entry.content,
timestamp: entry.timestamp.toISOString(), timestamp: entry.timestamp.toISOString(),
+2 -2
View File
@@ -63,7 +63,7 @@ export class DialogueService {
options: HandleMessageOptions = {}, options: HandleMessageOptions = {},
): Promise<HandleMessageResult> { ): Promise<HandleMessageResult> {
const now = options.now ?? new Date(); const now = options.now ?? new Date();
this.workingMemory.append(sessionId, { role: "user", content: userInput, timestamp: now }); await this.workingMemory.append(sessionId, { role: "user", content: userInput, timestamp: now });
// I-6:按需生成離線事件(今天只生成一次),成為話題來源。 // I-6:按需生成離線事件(今天只生成一次),成為話題來源。
await this.offlineEvent.maybeGenerate(characterId, now); await this.offlineEvent.maybeGenerate(characterId, now);
@@ -138,7 +138,7 @@ export class DialogueService {
this.outputFilter.filter(text, forbiddenWords), this.outputFilter.filter(text, forbiddenWords),
context.character.personalityArchetype, context.character.personalityArchetype,
); );
this.workingMemory.append(sessionId, { role: "character", content: filteredText, timestamp: now }); await this.workingMemory.append(sessionId, { role: "character", content: filteredText, timestamp: now });
return { text: filteredText, actions, isFastChannel, availability: effectiveAvailability, exceptionType, context }; return { text: filteredText, actions, isFastChannel, availability: effectiveAvailability, exceptionType, context };
} }
+30
View File
@@ -0,0 +1,30 @@
import type { EmotionTag } from "@kokorone/shared";
import { pickTemplates } from "./template-library.js";
export interface FewShotExample {
emotionTag: EmotionTag;
tier: "low" | "high";
userInput: string;
response: string;
}
const SAMPLE_USER_INPUT: Record<"low" | "high", string> = {
low: "今天天氣真好呢。",
high: "謝謝你,我今天好開心!",
};
const SAMPLE_EMOTIONS: EmotionTag[] = ["CALM", "JOY", "SAD", "ALERT", "SHY", "GRUMPY"];
// R-3:F-7 的 Mock 模板庫在真實 Provider 這邊轉為 few-shot 範例——同一份模板資料,
// 從「隨機挑一句直接當回應」變成「示範這個性格原型在各種情緒下該有的語氣」。
export function buildFewShotExamples(personalityArchetype: string): FewShotExample[] {
const examples: FewShotExample[] = [];
for (const tier of ["low", "high"] as const) {
for (const emotionTag of SAMPLE_EMOTIONS) {
const candidates = pickTemplates(personalityArchetype, emotionTag, tier);
if (candidates.length === 0) continue;
examples.push({ emotionTag, tier, userInput: SAMPLE_USER_INPUT[tier], response: candidates[0] });
}
}
return examples;
}
+2 -1
View File
@@ -16,10 +16,11 @@ import { DialogueService } from "./dialogue.service.js";
import { DialogueController } from "./dialogue.controller.js"; import { DialogueController } from "./dialogue.controller.js";
// F-2 Provider 切換:LLM_PROVIDER=mock|claude 決定注入哪個實作,切換不動引擎任何一行。 // F-2 Provider 切換:LLM_PROVIDER=mock|claude 決定注入哪個實作,切換不動引擎任何一行。
// R-3:ClaudeProvider 已接上真實 API,這裡不再需要特別的「尚未實作」警告。
function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) { function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
const providerName = process.env.LLM_PROVIDER ?? "mock"; const providerName = process.env.LLM_PROVIDER ?? "mock";
if (providerName === "claude") { if (providerName === "claude") {
log("啟動", "ERR", "LLM_PROVIDER=claude 但 ClaudeProvider 尚未實作(R-3),對話生成呼叫時會拋出例外"); log("啟動", "INF", `LLM_PROVIDER=claude,對話生成將呼叫真實 API(CLAUDE_BASE_URL=${process.env.CLAUDE_BASE_URL ?? "http://localhost:3000/api/v1"})`);
return claude; return claude;
} }
return mock; return mock;
+62
View File
@@ -0,0 +1,62 @@
import type { GenerationContext } from "./types.js";
import { buildFewShotExamples } from "./few-shot-examples.js";
export interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
const RELATIONSHIP_STAGE_LABEL: Record<string, string> = {
STRANGER: "陌生",
ACQUAINTANCE: "認識",
FRIEND: "朋友",
CLOSE_AMBIGUOUS: "摯友-曖昧",
BONDED: "羈絆",
};
function buildSystemPrompt(context: GenerationContext): string {
const { character, emotion, relationship, retrievedMemories } = context;
const stageLabel = RELATIONSHIP_STAGE_LABEL[relationship.stage] ?? relationship.stage;
const memoryLines =
retrievedMemories.length > 0
? retrievedMemories.map((m) => `- ${m.content}`).join("\n")
: "(目前沒有可用的相關記憶)";
const fewShot = buildFewShotExamples(character.personalityArchetype);
const fewShotLines = fewShot
.map((ex) => `情境:情緒=${ex.emotionTag}、親密度=${ex.tier}\n使用者:${ex.userInput}\n角色:${ex.response}`)
.join("\n\n");
return [
`你正在扮演一個戀愛陪伴系統中的角色,必須完全以第一人稱、角色本人的口吻回應,不要說明你是 AI,不要跳出角色。`,
``,
`【角色設定】`,
`性格原型:${character.personalityArchetype}`,
`說話風格:${character.speechStyle}`,
`喜好/厭惡:${character.likesDislikes}`,
`基本資訊:${character.basicInfo}`,
``,
`【當前狀態】`,
`情緒:${emotion.dominant}(語氣:${emotion.style.tone},句長:${emotion.style.sentenceLength},主動性:${emotion.style.initiative})`,
`關係:親密度 ${relationship.intimacy}、信任度 ${relationship.trust}、階段:${stageLabel}`,
``,
`【相關記憶】`,
memoryLines,
``,
`【輸出格式】`,
`回應為純文字。若有動作/表情描寫,用全形星號包住,例如 *臉紅撇過頭*;不要用任何其他標記語法,不要加角色名稱前綴。`,
``,
`【語氣範例(僅供語氣參考,不要照抄內容)】`,
fewShotLines,
].join("\n");
}
export function buildMessages(context: GenerationContext): ChatMessage[] {
const messages: ChatMessage[] = [{ role: "system", content: buildSystemPrompt(context) }];
for (const turn of context.history) {
messages.push({ role: turn.role === "character" ? "assistant" : "user", content: turn.content });
}
messages.push({ role: "user", content: context.userInput });
return messages;
}