feat(llm 對話): 新增 prompt builder 與 few-shot 範例組裝,強化 Claude 對話品質
This commit is contained in:
@@ -1,19 +1,101 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { LLMProvider } from "./llm-provider.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()
|
||||
export class ClaudeProvider implements LLMProvider {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async generate(_context: GenerationContext): Promise<GeneratedResponse> {
|
||||
throw new Error(NOT_IMPLEMENTED_MESSAGE);
|
||||
async generate(context: GenerationContext): Promise<GeneratedResponse> {
|
||||
const res = await requestChatCompletion({
|
||||
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> {
|
||||
throw new Error(NOT_IMPLEMENTED_MESSAGE);
|
||||
async *stream(context: GenerationContext): AsyncIterable<string> {
|
||||
const res = await requestChatCompletion({
|
||||
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 });
|
||||
|
||||
const history = this.workingMemory.getContext(sessionId).map((entry) => ({
|
||||
const history = (await this.workingMemory.getContext(sessionId)).map((entry) => ({
|
||||
role: entry.role,
|
||||
content: entry.content,
|
||||
timestamp: entry.timestamp.toISOString(),
|
||||
|
||||
@@ -63,7 +63,7 @@ export class DialogueService {
|
||||
options: HandleMessageOptions = {},
|
||||
): Promise<HandleMessageResult> {
|
||||
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:按需生成離線事件(今天只生成一次),成為話題來源。
|
||||
await this.offlineEvent.maybeGenerate(characterId, now);
|
||||
@@ -138,7 +138,7 @@ export class DialogueService {
|
||||
this.outputFilter.filter(text, forbiddenWords),
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -16,10 +16,11 @@ import { DialogueService } from "./dialogue.service.js";
|
||||
import { DialogueController } from "./dialogue.controller.js";
|
||||
|
||||
// F-2 Provider 切換:LLM_PROVIDER=mock|claude 決定注入哪個實作,切換不動引擎任何一行。
|
||||
// R-3:ClaudeProvider 已接上真實 API,這裡不再需要特別的「尚未實作」警告。
|
||||
function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
|
||||
const providerName = process.env.LLM_PROVIDER ?? "mock";
|
||||
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 mock;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user