R-2/R-3:Redis 快取層與 LLM 對話品質強化 + pnpm/turbo 遷移 #1

Merged
admin merged 6 commits from develop into master 2026-08-14 04:56:21 +00:00
24 changed files with 12328 additions and 17829 deletions
+11
View File
@@ -1,3 +1,14 @@
# 複製為 .env 後依需要調整。
# R-2 階段會改為 postgresql 連線字串(例如 postgresql://user:password@localhost:5432/kokorone)。
DATABASE_URL="file:./prisma/dev.db"
# R-2:情緒狀態即時讀寫/session 快取的加速層;連不到時自動退回原本行為(DB/行程記憶體),不是必要依賴。
REDIS_URL=redis://127.0.0.1:6379
# R-3:LLM_PROVIDER=mock(預設,冒煙測試固定用這個)|claude(真的呼叫 API)
LLM_PROVIDER=mock
# 這個部署走 CLIProxy(OpenAI 相容的分散式派工代理)而不是直連 api.anthropic.com,
# 底層仍是 Claude;若改直連官方 API,把 CLAUDE_BASE_URL 換成 https://api.anthropic.com/v1 即可。
CLAUDE_BASE_URL=http://localhost:3000/api/v1
CLAUDE_API_KEY=
CLAUDE_MODEL=claude-sonnet-4-5
+65 -1
View File
@@ -2,4 +2,68 @@
以人腦「記憶/情緒/關係」架構為引擎基礎的戀愛陪伴系統。
> 目前使用 npm workspaces 開發(本機無 pnpm),待 R-1 階段會遷移至 pnpm + Turborepo,對外指令介面(`npm run restart` / `npm run smoke`)保持不變。
## 開發環境
Monorepo 採 pnpm workspace + Turborepo(`pnpm-workspace.yaml` / `turbo.json`)。
```bash
pnpm install # 安裝所有 workspace 套件
pnpm run build # turbo run build(依相依順序建置 shared/db/api,並建置 web 的 production bundle)
pnpm run restart # 停止舊的 api/web 行程、重建 TypeScript(tsc -b)、重新啟動並等待健康檢查
pnpm run smoke A # 執行 A 群組冒煙測試;all 表示依序執行 A~R 全部群組
```
對外指令介面(`restart` / `smoke`)維持與遷移前相同的腳本名稱;唯一差異是
**pnpm 不會像 npm 一樣吃掉 `--` 分隔符**,`pnpm run smoke -- A` 會把字面上的
`--` 一起傳給腳本而找不到群組,要執行子指令請直接接在腳本名稱後面
(`pnpm run smoke A`),不要加 `--`。
## 環境變數
複製 `.env.example` 為 `.env` 後依需要調整;`apps/web/.env.local` 另外可設定
`NEXT_PUBLIC_API_URL`(瀏覽器端呼叫 api 用,預設 `http://localhost:3001`)。
| 變數 | 預設值 | 說明 |
| --- | --- | --- |
| `DATABASE_URL` | `file:./prisma/dev.db` | Prisma 連線字串。R-5 若遷移到 PostgreSQL,改為 `postgresql://user:password@host:5432/db` 並將 `prisma/schema.prisma` 的 `provider` 由 `sqlite` 換成 `postgresql`。 |
| `PORT` | `3001` | api(NestJS)監聽埠。 |
| `PORT_API` / `PORT_WEB` | `3001` / `3100` | `pnpm run restart`(`scripts/dev-restart.mjs`)啟動 api/web 時使用的埠號。 |
| `WEB_ORIGIN` | `http://localhost:3100` | api 的 CORS 允許來源之一;`http://localhost:8081`(Expo web 開發伺服器)已固定加入,不受此變數影響。 |
| `NEXT_PUBLIC_API_URL` | `http://localhost:3001` | web(Next.js)呼叫 api 的位址,build time 注入,正式環境需在建置時設定。 |
| `LLM_PROVIDER` | `mock` | `mock`(模板庫,冒煙測試固定用這個)/`claude`(呼叫真實 API,見下)。 |
| `CLAUDE_BASE_URL` | `http://localhost:3000/api/v1` | OpenAI 相容的 chat completions 端點基底路徑。本專案的部署方式是透過 [CLIProxy](https://gitea.jsc.idv.tw/jiantw83/CLIProxy)(分散式派工代理,本機 3000 埠)轉發到真實 Claude,而非直連 `api.anthropic.com`;若要直連官方 API,把這個值換成 `https://api.anthropic.com/v1` 並確認金鑰/請求格式相容即可,`ClaudeProvider` 的呼叫端程式碼不需要改動。 |
| `CLAUDE_API_KEY` | (無) | 呼叫 `CLAUDE_BASE_URL` 用的金鑰,`LLM_PROVIDER=claude` 時必填。 |
| `CLAUDE_MODEL` | `claude-sonnet-4-5` | 要求 Provider 使用的模型名稱;CLIProxy 端需要有對應的 Worker 已註冊、能接這個模型的派工,否則會回報「沒有任何可用的 {模型}-{執行者}-{工具} 組合」。 |
| `TTS_PROVIDER` | `mock` | `mock`/`real`(P-4 空殼,尚未接上真實供應商,人工確認後才實作)。 |
| `PUSH_PROVIDER` | `mock` | `mock`/`expo`(Q-3,`expo` 直接可用,不需金鑰)。 |
## 部署路徑
開發環境:以上服務直接用 `node`/`next dev`/`expo start` 跑在同一台 Linux 主機上
(`pnpm run restart` 一鍵重啟 api/web),**不使用 Docker**——這是本專案的實際
部署選擇,取代原先技術選型草案中「Docker Compose(開發)」的規劃。
正式環境建議路徑:
1. **建置**:`pnpm install --frozen-lockfile && pnpm run build`(turbo 依序建置
`packages/shared`/`packages/db`/`apps/api`,並產出 `apps/web` 的 production
bundle)。
2. **資料庫遷移**:`npx prisma migrate deploy`(依 `DATABASE_URL` 指向的資料庫套用
遷移;R-5 遷移到 PostgreSQL 後流程相同,只是連線字串換成 postgresql)。
3. **啟動**:
- api:`node apps/api/dist/main.js`(讀取上表環境變數)。
- web:`apps/web` 下 `npx next start -p <PORT_WEB>`(讀取建置時注入的
`NEXT_PUBLIC_API_URL`)。
- mobile:`apps/mobile` 透過 `eas build` 產出實際的 App Store/Google Play
安裝檔,不隨 api/web 一起常駐部署。
4. **程序常駐與重啟**:用 systemd unit(或 pm2)分別管理 api/web 兩個
Node 行程,設定 `Restart=on-failure`;健康檢查沿用 `GET /health`
(api)與首頁 200(web),與 `scripts/dev-restart.mjs` 開發環境用的
檢查邏輯一致。
5. **反向代理/TLS**:nginx 或雲端負載平衡器終止 TLS 後轉發到 api/web
對應埠號,對外只曝露 443。
這個路徑刻意不假設 K8s 或容器化,只依賴「Node.js 行程 + systemd + nginx」,
因為這台開發主機本身就是照這個模式跑(`pnpm run restart` 正是這個模式的
開發期簡化版),移植到正式主機時只是把「用 node 直接跑」換成「用 systemd
管理同一組 node 指令」,中間不需要引入額外的容器化工具鏈。
+3 -2
View File
@@ -10,13 +10,14 @@
"start": "node dist/main.js"
},
"dependencies": {
"@kokorone/db": "*",
"@kokorone/shared": "*",
"@kokorone/db": "workspace:*",
"@kokorone/shared": "workspace:*",
"@nestjs/common": "^11.1.29",
"@nestjs/core": "^11.1.29",
"@nestjs/event-emitter": "^3.1.0",
"@nestjs/platform-express": "^11.1.29",
"dotenv": "^17.4.2",
"ioredis": "^6.0.0",
"node-cron": "^4.6.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
+26
View File
@@ -0,0 +1,26 @@
import { Redis } from "ioredis";
import { log } from "@kokorone/shared";
let client: Redis | null = null;
let loggedUnavailable = false;
// R-2:session 快取/情緒狀態即時讀寫改走 Redis。連線失敗時呼叫端一律要能安全退回原本的行為
// (工作記憶退回行程記憶體 Map、情緒狀態退回直接讀寫 Prisma),Redis 只是加速層,不是新的必要依賴,
// 這樣即使部署環境還沒有 Redis(例如這個沒有 Docker/sudo 的沙盒),既有行為也不會被打斷。
export function getRedisClient(): Redis {
if (!client) {
client = new Redis(process.env.REDIS_URL ?? "redis://127.0.0.1:6379", {
lazyConnect: true,
maxRetriesPerRequest: 1,
retryStrategy: () => null,
connectTimeout: 300,
});
client.on("error", () => {
if (!loggedUnavailable) {
loggedUnavailable = true;
log("啟動", "WRN", `Redis 無法連線(REDIS_URL=${process.env.REDIS_URL ?? "redis://127.0.0.1:6379"}),相關快取將退回原本行為`);
}
});
}
return client;
}
+38
View File
@@ -5,6 +5,9 @@ import { RuleBasedEmotionTagger } from "./emotion-tagger.js";
import { nextEmotionState, type TransitionContext } from "./emotion-state-machine.js";
import { toResponseStyle, type ResponseStyle } from "./response-style.js";
import { getArchetypeParams } from "../personality/archetype-params.js";
import { getRedisClient } from "../cache/redis-client.js";
const REDIS_KEY_PREFIX = "kokorone:emotion-state:";
// D-2 時間衰減:預設半衰期 2 小時,實際速度由角色參數(G 群組)覆寫。
const DEFAULT_HALF_LIFE_MS = 2 * 60 * 60 * 1000;
@@ -133,11 +136,45 @@ export class EmotionService {
return { state: toDomain(characterId, updated, now), style: toResponseStyle(next) };
}
// R-2:情緒狀態機的即時讀寫改走 Redis 當熱路徑快取,Prisma/SQLite(未來 Postgres)
// 仍是唯一的持久真實來源——快取只省掉命中時的資料庫往返,Redis 不可用或未命中都直接退回原本
// 讀 Prisma 的行為,因此「重啟 api 後情緒狀態不遺失」這件事完全不受快取層是否存在影響。
private async readCached(characterId: string): Promise<{ dims: Dimensions; updatedAt: Date } | null> {
try {
const raw = await getRedisClient().get(REDIS_KEY_PREFIX + characterId);
if (!raw) return null;
const parsed = JSON.parse(raw) as Dimensions & { updatedAt: string };
const { updatedAt, ...dims } = parsed;
return { dims, updatedAt: new Date(updatedAt) };
} catch {
return null;
}
}
private async writeCache(characterId: string, dims: Dimensions, updatedAt: Date): Promise<void> {
try {
await getRedisClient().set(
REDIS_KEY_PREFIX + characterId,
JSON.stringify({ ...dims, updatedAt: updatedAt.toISOString() }),
"EX",
60 * 60 * 24,
);
} catch {
// Redis 不可用時,Prisma 仍是持久真實來源,快取寫入失敗不影響正確性。
}
}
private async decayedDimensions(
characterId: string,
now: Date,
halfLifeMs = DEFAULT_HALF_LIFE_MS,
): Promise<{ dims: Dimensions; updatedAt: Date }> {
const cached = await this.readCached(characterId);
if (cached) {
const elapsedMs = now.getTime() - cached.updatedAt.getTime();
return { dims: decayDimensions(cached.dims, elapsedMs, halfLifeMs), updatedAt: cached.updatedAt };
}
const row = await this.prisma.client.emotionState.upsert({
where: { characterId },
update: {},
@@ -154,6 +191,7 @@ export class EmotionService {
where: { characterId },
data: { ...dims, updatedAt: now },
});
await this.writeCache(characterId, dims, now);
}
}
+90 -8
View File
@@ -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(),
+2 -2
View File
@@ -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 };
}
+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";
// 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;
+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;
}
@@ -17,7 +17,7 @@ export class MemoryConsolidationService {
) {}
async consolidate(characterId: string, sessionId: string): Promise<void> {
const entries = this.workingMemory.getContext(sessionId);
const entries = await this.workingMemory.getContext(sessionId);
const contentCounts = new Map<string, number>();
for (const entry of entries) {
@@ -53,6 +53,6 @@ export class MemoryConsolidationService {
});
}
this.workingMemory.clear(sessionId);
await this.workingMemory.clear(sessionId);
}
}
+5 -5
View File
@@ -27,20 +27,20 @@ export class MemoryController {
) {}
@Post(":characterId/sessions/:sessionId/messages")
appendMessage(@Param("sessionId") sessionId: string, @Body() body: AppendMessageBody) {
this.workingMemory.append(sessionId, {
async appendMessage(@Param("sessionId") sessionId: string, @Body() body: AppendMessageBody) {
await this.workingMemory.append(sessionId, {
role: body.role,
content: body.content,
timestamp: new Date(),
emotionTag: body.emotionTag,
emotionIntensity: body.emotionIntensity,
});
return { context: this.workingMemory.getContext(sessionId) };
return { context: await this.workingMemory.getContext(sessionId) };
}
@Get(":characterId/sessions/:sessionId/messages")
getContext(@Param("sessionId") sessionId: string) {
return { context: this.workingMemory.getContext(sessionId) };
async getContext(@Param("sessionId") sessionId: string) {
return { context: await this.workingMemory.getContext(sessionId) };
}
@Post(":characterId/sessions/:sessionId/consolidate")
+64 -23
View File
@@ -1,6 +1,7 @@
import { Injectable } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared";
import { HIGH_EMOTION_THRESHOLD } from "./constants.js";
import { getRedisClient } from "../cache/redis-client.js";
export interface WorkingMemoryEntry {
role: "user" | "character";
@@ -10,7 +11,14 @@ export interface WorkingMemoryEntry {
emotionIntensity?: number; // 0~1,未標記情緒的訊息(如快速通道問候)可省略
}
interface SerializedEntry extends Omit<WorkingMemoryEntry, "timestamp"> {
timestamp: string;
}
const DEFAULT_TOKEN_LIMIT = 200;
const REDIS_KEY_PREFIX = "kokorone:working-memory:";
// session 閒置這麼久沒有新訊息就任由 Redis 自然過期,避免無限累積殭屍 session。
const SESSION_TTL_SECONDS = 60 * 60 * 6;
// 粗略估算:Mock 階段不需要真實 tokenizer,先以字元數/2 近似。
function estimateTokens(text: string): number {
@@ -21,32 +29,11 @@ function isHighEmotion(entry: WorkingMemoryEntry): boolean {
return (entry.emotionIntensity ?? 0) >= HIGH_EMOTION_THRESHOLD;
}
// C-1 工作記憶:session 對話上下文緩衝,只存在於行程記憶體中(如同海馬迴暫存),
// 不落地到任何長期記憶表——長期寫入只能由 C-4 睡眠固化觸發。
@Injectable()
export class WorkingMemoryService {
private readonly sessions = new Map<string, WorkingMemoryEntry[]>();
private readonly tokenLimit = DEFAULT_TOKEN_LIMIT;
append(sessionId: string, entry: WorkingMemoryEntry): void {
const entries = this.sessions.get(sessionId) ?? [];
entries.push(entry);
this.sessions.set(sessionId, this.evictOverflow(entries));
}
getContext(sessionId: string): WorkingMemoryEntry[] {
return [...(this.sessions.get(sessionId) ?? [])];
}
clear(sessionId: string): void {
this.sessions.delete(sessionId);
}
private evictOverflow(entries: WorkingMemoryEntry[]): WorkingMemoryEntry[] {
function evictOverflow(entries: WorkingMemoryEntry[], tokenLimit: number): WorkingMemoryEntry[] {
let result = entries;
let totalTokens = result.reduce((sum, e) => sum + estimateTokens(e.content), 0);
while (totalTokens > this.tokenLimit && result.length > 0) {
while (totalTokens > tokenLimit && result.length > 0) {
// 最舊的低情緒段落先被裁掉;若全部都是高情緒,最後才犧牲最舊的一則以確保不超出上限。
let evictIndex = result.findIndex((e) => !isHighEmotion(e));
if (evictIndex === -1) {
@@ -57,5 +44,59 @@ export class WorkingMemoryService {
}
return result;
}
function serialize(entries: WorkingMemoryEntry[]): string {
const payload: SerializedEntry[] = entries.map((e) => ({ ...e, timestamp: e.timestamp.toISOString() }));
return JSON.stringify(payload);
}
function deserialize(raw: string): WorkingMemoryEntry[] {
const payload = JSON.parse(raw) as SerializedEntry[];
return payload.map((e) => ({ ...e, timestamp: new Date(e.timestamp) }));
}
// C-1 工作記憶:session 對話上下文緩衝,如同海馬迴暫存,長期寫入只能由 C-4 睡眠固化觸發。
// R-2:改走 Redis(session 快取),Redis 不可用時退回行程記憶體 Map,行為(token 上限、高情緒優先保留)不變。
@Injectable()
export class WorkingMemoryService {
private readonly fallback = new Map<string, WorkingMemoryEntry[]>();
private readonly tokenLimit = DEFAULT_TOKEN_LIMIT;
async append(sessionId: string, entry: WorkingMemoryEntry): Promise<void> {
const entries = await this.getContext(sessionId);
entries.push(entry);
const evicted = evictOverflow(entries, this.tokenLimit);
const redis = getRedisClient();
try {
await redis.set(REDIS_KEY_PREFIX + sessionId, serialize(evicted), "EX", SESSION_TTL_SECONDS);
this.fallback.delete(sessionId);
return;
} catch {
this.fallback.set(sessionId, evicted);
}
}
async getContext(sessionId: string): Promise<WorkingMemoryEntry[]> {
const redis = getRedisClient();
try {
const raw = await redis.get(REDIS_KEY_PREFIX + sessionId);
if (raw !== null) return deserialize(raw);
if (this.fallback.has(sessionId)) return [...this.fallback.get(sessionId)!];
return [];
} catch {
return [...(this.fallback.get(sessionId) ?? [])];
}
}
async clear(sessionId: string): Promise<void> {
this.fallback.delete(sessionId);
const redis = getRedisClient();
try {
await redis.del(REDIS_KEY_PREFIX + sessionId);
} catch {
// Redis 不可用時 fallback 已經在上面清掉,沒有需要額外處理的狀態。
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
"main": "expo-router/entry",
"dependencies": {
"@expo/metro-runtime": "~57.0.9",
"@kokorone/shared": "*",
"@kokorone/shared": "workspace:*",
"expo": "~57.0.12",
"expo-constants": "~57.0.10",
"expo-device": "~57.0.1",
+1 -1
View File
@@ -9,7 +9,7 @@
"lint": "eslint"
},
"dependencies": {
"@kokorone/shared": "*",
"@kokorone/shared": "workspace:*",
"next": "16.3.0",
"pixi.js": "^8.19.0",
"react": "19.2.8",
-17332
View File
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -3,12 +3,9 @@
"version": "0.1.0",
"private": true,
"description": "心音(Kokorone)— 戀愛陪伴系統 monorepo",
"workspaces": [
"apps/*",
"packages/*"
],
"packageManager": "pnpm@11.21.0",
"scripts": {
"build": "npm run build --workspaces --if-present",
"build": "turbo run build",
"restart": "node scripts/dev-restart.mjs",
"smoke": "node scripts/smoke/run.mjs"
},
@@ -19,6 +16,7 @@
"@types/node": "^26.2.0",
"dotenv": "^17.4.2",
"prisma": "^7.9.1",
"turbo": "^2.5.8",
"typescript": "^7.0.2"
}
}
+11884
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
packages:
- "apps/*"
- "packages/*"
allowBuilds:
'@prisma/engines': true
better-sqlite3: true
prisma: true
unrs-resolver: true
+12 -7
View File
@@ -136,14 +136,18 @@ export default async function smokeF() {
// 清理本次測試建立的 ProceduralRule,避免重複執行時累積髒資料
await prisma.proceduralRule.deleteMany({ where: { characterId: CHARACTER_ID, situation } });
// F-2 Provider 切換:LLM_PROVIDER=claude 時啟動即以 ERR log 明確告知未實作,呼叫時拋出例外
await verifyClaudeProviderStub();
// R-3:ClaudeProvider 已經是真的呼叫,不再是空殼。這裡只驗證 Provider 切換的「配線」本身
// (選對了 ClaudeProvider、缺金鑰時的錯誤處理正確),不對 CLIProxy 發真的請求——真正的
// 回歸測試基準仍是 Mock 模板庫(同 R-3 驗收註記),自動化冒煙測試不應依賴外部服務目前是否可用。
await verifyClaudeProviderWiring();
}
async function verifyClaudeProviderStub() {
async function verifyClaudeProviderWiring() {
const port = "3091";
const childEnv = { ...process.env, PORT: port, LLM_PROVIDER: "claude" };
delete childEnv.CLAUDE_API_KEY;
const child = spawn("node", ["apps/api/dist/main.js"], {
env: { ...process.env, PORT: port, LLM_PROVIDER: "claude" },
env: childEnv,
stdio: ["ignore", "pipe", "pipe"],
});
@@ -156,8 +160,8 @@ async function verifyClaudeProviderStub() {
while (!output.includes("啟動") && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 200));
}
if (!output.includes("ClaudeProvider 尚未實作")) {
throw new Error("LLM_PROVIDER=claude 啟動時未輸出「尚未實作」的 ERR log");
if (!output.includes("LLM_PROVIDER=claude")) {
throw new Error("LLM_PROVIDER=claude 啟動時未輸出對應的 INF log");
}
let healthOk = false;
@@ -182,8 +186,9 @@ async function verifyClaudeProviderStub() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: USER_ID, text: "今天發生了很多事情想跟你分享" }),
});
// 沒有金鑰應該在呼叫 generate() 時就快速、確定性地失敗(不對 CLIProxy 發任何請求)。
if (res.status < 500) {
throw new Error(`ClaudeProvider 空殼應在呼叫 generate() 時失敗,實際回傳 ${res.status}`);
throw new Error(`缺少 CLAUDE_API_KEY 時應該呼叫失敗,實際回傳 ${res.status}`);
}
} finally {
child.kill("SIGKILL");
+1 -1
View File
@@ -225,7 +225,7 @@ export default async function smokeJ() {
});
const { spawnSync } = await import("node:child_process");
const restartResult = spawnSync("npm", ["run", "restart"], { cwd: process.cwd(), stdio: "pipe" });
const restartResult = spawnSync("pnpm", ["run", "restart"], { cwd: process.cwd(), stdio: "pipe" });
if (restartResult.status !== 0) {
throw new Error(`重啟服務失敗:${restartResult.stderr?.toString()}`);
}
-432
View File
@@ -1,432 +0,0 @@
---
model: claude-sonnet-5
model_alias: sonnet
model_reason: 本清單的來源(Kokorone 系統架構計畫 wiki)已把每個子系統的機制、資料結構、對照表與判定規則寫到規格層級,清單本身也已拆解到「動詞+對象+驗收條件」的可直接動手粒度,實作時的主要工作是照規格落地程式碼、逐階段補齊介面與測試,而不是重新做架構決策;工作量體大、階段多、需長時間連續產出,選擇具備 #均衡實作 #實作 #本機可用 且加分命中 #中成本 的 claude-sonnet-5(1M 上下文足以一次讀入本清單與跨檔案脈絡)。本欄為本 skill 依 spec-model 第二節推薦的結果,非使用者指定。
analyzed_by: claude-opus-5[1m]
analyzed_at: 2026/08/12 17:27:05
scope: /home/h3285/jsc/jiantw83/Kokorone(全新 monorepo,目前為空目錄);需求來源 https://gitea.jsc.idv.tw/knowledges/Plan/wiki/Home 及其兩個子頁(Kokorone 系統架構計畫、Kokorone 主視覺與資產);涵蓋全系統所有子系統(記憶/情緒/關係/人格/對話模式/作息/委託/戀愛軸與尺度/原作考據與輕小說管線/後日談/立繪/語音/網頁/APP/正式基礎設施)
---
# Kokorone(心音)實作清單
## 0. 給執行本清單 Agent 的強制規則(先讀完再動手)
| 規則 | 內容 |
| --- | --- |
| **模型鎖定** | 本檔 frontmatter 的 `model` 是**強制**的,不是建議。開工前先自我確認當前模型 id。 |
| **不符就停** | 當前模型 ≠ `claude-sonnet-5` 時,**立刻停止、不做任何檔案修改**,輸出下方錯誤訊息並要求使用者切換。 |
| **不得自行升降級** | 不可以「先用手上的模型做一點」、不可以自行判定「我這顆更強所以沒關係」。降級與升級同樣禁止。 |
| **附加不覆蓋** | 若之後要往本檔追加新需求:`model` 相同 → 附加到檔尾;`model` 不同 → 先問使用者是否覆蓋,未得同意不得寫入。 |
```
[2026/08/12 17:27:05][模型檢查][ERR]: 本清單指定 claude-sonnet-5(sonnet),當前模型為 <current-model-id>。
請執行 /model sonnet 切換後重新載入本清單,本次不進行任何修改。
```
> 當前模型 id 的取得方式:Claude Code 沒有提供模型 id 的環境變數,agent 依自身系統提示所述的 exact model ID 自我回報即可;無法確定時請使用者以 `/status` 確認,**不要用猜的**。
---
## 1. 需求彙整
### 目標
建立戀愛陪伴系統「心音(Kokorone)」:以人腦「記憶/情緒/行為」架構為引擎基礎,角色具備真實記憶、會累積衰減的情緒、關係帳本與自己的生活作息,戀愛關係的推進是核心體驗(依據:系統架構計畫§目標)。
### 本次清單的執行方式(使用者於本次分析中確認)
| 決策 | 內容 |
| --- | --- |
| 範圍 | **全系統完整拆解**——含語音、立繪、APP、輕小說章節萃取管線、後日談等所有子系統 |
| 執行環境 | **先純 Node**:本機目前有 node v26.3.0/npm 11.16.0/git,**沒有 pnpm、Docker、PostgreSQL、Redis**。第一階段用 npm workspaces + Prisma/SQLite 跑起來,資料層與佇列以介面抽象,最後一個群組(R)再遷移到 pnpm+Turborepo、PostgreSQL+pgvector、Redis、BullMQ、Docker Compose |
| 階段驗收 | **每個群組的最後一項固定是 `<群組>-V`**:重啟服務 → 健康檢查 → 跑該階段冒煙測試,三者皆通過才算該階段完成 |
### 驗收條件(全域)
1. 每個群組結束時 `npm run restart` 能停掉舊行程並重新啟動 api 與 web,`GET /health` 回 200。
2. 每個群組結束時 `npm run smoke -- <群組代號>` 全綠。
3. 所有面向使用者的文字、註解、log 為繁體中文(台灣用語),log 格式 `[yyyy/MM/dd HH:mm:ss][階段][等級]: 訊息`(Asia/Taipei)。
4. 引擎層規則(尺度硬邊界、排程可靠性)不因角色演出打折——演出只在輸出層。
### 限制條件
| 限制 | 來源 |
| --- | --- |
| 對話生成第一階段一律走 MockProvider,不呼叫真實 LLM API(零費用、可重現) | 技術選型§LLM Provider 抽象層 |
| 硬邊界(未成年角色相關、非合意、暴力性內容)在**引擎層**執行,永不可解除 | §內容尺度 |
| 正史未成年→後日談自然成年的角色:戀愛軸可用但**永久限純愛尺度**,成年前戀愛軸完全關閉,時間流速上限 1:1 | §後日談成年與戀愛軸資格:兩級制 |
| 既有作品角色的形象、聲音、輕小說文本與插圖屬原作版權方,本專案以個人使用為前提 | §既有作品角色的再現、§插圖使用限制 |
| 本機無 Docker/PG/Redis,R 群組之前不得依賴這些服務 | 本次環境檢查結果 |
### 需人工確認(來源未提及,實作到該處前必須先問使用者)
- 成人模式的「成年使用者驗證」採用什麼機制(wiki 只寫「成年使用者驗證+opt-in」,未指定實作方式)。
- TTS/STT 供應商與 Live2D Cubism SDK 授權(wiki 只寫「TTS 服務」「Live2D/PixiJS」,未指定廠商與授權方案)。
- 使用者帳號與登入機制(wiki 未提及)。
- APP 階段是否有可用的模擬器或實體裝置可驗證。
- 輕小說章節匯入用的測試文本來源(不得直接使用受版權保護的原文,冒煙測試改用自製測試文本)。
---
## 2. 執行順序
群組之間有強制先後,**不可跳做**:
```mermaid
flowchart TB
A[A 專案骨架與可重啟服務] --> B[B 資料層與領域模型]
B --> C[C 記憶子系統]
B --> D[D 情緒子系統]
B --> E[E 關係子系統]
C & D & E --> F[F 對話生成與 LLM Provider]
F --> G[G 角色人格層]
G --> H[H 網頁對話介面<br/>第一個可用垂直切片]
H --> I[I 生活作息與離線生活]
I --> J[J 委託子系統]
J --> K[K 群聊與角色自聊]
K --> L[L 戀愛關係軸與內容尺度]
L --> M[M 原作考據與輕小說管線]
M --> N[N 後日談模式]
H --> O[O 立繪子系統]
O --> P[P 語音子系統]
P --> Q[Q APP]
N & Q --> R[R 正式基礎設施遷移]
```
| 先後 | 理由 |
| --- | --- |
| A 最先 | 沒有可啟動、可重啟、可冒煙測試的服務,後面每個階段的驗收條件都無從執行 |
| B 在 C/D/E 之前 | 記憶、情緒、關係都要落地到資料表 |
| C/D/E 在 F 之前 | 上下文組裝需要三者的輸出 |
| G 在 H 之前 | 前端要顯示的情緒晶片、稱呼、動作描寫來自人格層 |
| H 之後才做 I~N | H 完成即第一個可實際對話的垂直切片,後續子系統都能立刻在真實介面上驗證 |
| O/P 在 H 之後、Q 之前 | APP 需要立繪與語音已可用 |
| R 最後 | 遷移基礎設施前,所有功能已在純 Node 環境驗證過,遷移只需確認行為不變 |
**編號規則**:`<群組代號>-<序號>`,各群組最後一項固定為 `<群組代號>-V`(驗證項)。日後追加項目沿用同規則接續序號,不得重複或跳號。群組內依影響範圍由小到大排序(XS→XL),`-V` 項固定置尾。
---
## 3. TODO
### A. 專案骨架與可重啟的服務
- [x] **A-1 建立 monorepo 根目錄(XS)**:於 `/home/h3285/jsc/jiantw83/Kokorone` 建立 `package.json`(npm workspaces:`apps/*`、`packages/*`)、`.gitignore`、`.editorconfig`、`README.md`(一句話說明專案),執行 `git init` 並完成首次 commit。驗收:根目錄 `npm run` 可列出腳本、`git log` 有一筆提交。依據:技術選型「單一 monorepo 三端共用邏輯」;因本機無 pnpm,先用 npm workspaces 並在 README 註記 R-1 會遷移。
- [x] **A-2 TypeScript 基礎設定(XS)**:建立根 `tsconfig.base.json`(`strict: true`),各 workspace 以 `extends` 繼承;建立 `packages/shared` 空套件並匯出一個型別驗證編譯鏈路。驗收:`npx tsc -b` 零錯誤。依據:技術選型「TypeScript:三端共用型別」。
- [x] **A-3 統一日誌模組(S)**:於 `packages/shared/src/log.ts` 實作 `[yyyy/MM/dd HH:mm:ss][階段][等級]: 訊息` 格式輸出(時區 Asia/Taipei,等級限 INF/WRN/ERR/TRC/DBG,一行一則),api 與 web 的伺服端輸出一律走此模組。驗收:啟動 api 時輸出符合格式的啟動訊息。
- [x] **A-4 建立 apps/api 最小 NestJS 服務(S)**:實作 `GET /health` 回 `{ status: 'ok', version, uptime }`,連接埠讀 `PORT`(預設 3001)。驗收:`curl localhost:3001/health` 回 200 且含 `status: ok`。依據:技術選型「後端 NestJS + Socket.IO」。
- [x] **A-5 建立 apps/web 最小 Next.js 頁面(S)**:App Router + Tailwind,首頁顯示「心音 Kokorone」標題與心跳波形 SVG,套用色票 心動粉 `#FF7E9D`/暮空紫 `#8C7AE6`/晨霧白 `#FFF8FA`/夜空藍黑 `#1A1430`/墨字 `#3A3242`,並以 CSS 變數實作亮/暗模式(暗色模式為一級公民)。驗收:`http://localhost:3000` 顯示標題與波形,切換系統深色模式配色正確。依據:主視覺與資產§色彩、§視覺母題。
- [x] **A-6 重啟與健康檢查機制(M)**:實作 `scripts/dev-restart.mjs`——讀 `.dev-pids` 停掉舊行程 → 背景啟動 api 與 web → 輪詢 `GET /health` 與 web 首頁直到皆回 200(逾時 60 秒則以非零狀態結束並輸出 ERR log);`package.json` 加上 `npm run restart` 指向它。驗收:連續執行兩次 `npm run restart` 不會殘留重複行程且皆成功。依據:使用者要求「每完成一個階段就重新啟動服務」。
- [x] **A-7 冒煙測試框架(M)**:實作 `scripts/smoke/run.mjs`,以 `npm run smoke -- <群組代號>` 執行 `scripts/smoke/<群組代號>.mjs`;建立 `scripts/smoke/A.mjs`(檢查 `/health` 回 200、web 首頁含「心音」字樣),失敗以非零狀態結束。驗收:`npm run smoke -- A` 全綠。
- [x] **A-V 階段驗證(XS)**:執行 `npm run restart && npm run smoke -- A`,兩者皆通過才算本階段完成;失敗則修到通過為止,不得跳到 B。
> **實作記錄(A 群組)**:本機 port 3000 已被既有非本專案服務佔用,web 開發埠改為 **3100**(`PORT_WEB`,可覆寫),api 維持 **3001**(`PORT_API`)。後續所有群組提及 `localhost:3000` 之處一律改讀 `localhost:3100`。
### B. 資料層與核心領域模型
- [x] **B-1 導入 Prisma + SQLite(XS)**:安裝 Prisma,建立 `prisma/schema.prisma`(`provider = "sqlite"`),連線字串走 `DATABASE_URL` 並提供 `.env.example`;在 schema 檔頭註記「R-2 會切換為 postgresql + pgvector」。驗收:`npx prisma migrate dev` 成功產生資料庫檔。依據:技術選型「PostgreSQL + Prisma ORM」;本階段依環境限制先用 SQLite。
- [x] **B-2 共用型別套件(S)**:`packages/shared` 匯出角色、記憶、情緒、關係的 TypeScript 型別,api 與 web 皆從此匯入,不各自定義。驗收:api 與 web 皆能編譯通過且無重複型別定義。依據:技術選型「packages/shared:型別/狀態邏輯/API client 三端共用」。
- [x] **B-3 種子資料(S)**:`prisma/seed.ts` 建立一位測試使用者與一位原創測試角色(元氣型,含說話方式與喜惡),供後續所有階段驗證使用。驗收:`npx prisma db seed` 後 `GET /characters` 回傳該角色。
- [x] **B-4 作品與角色資料表(M)**:定義 `Work`(書名/卷數進度/世界觀/作品進度錨定點)、`Character`(角色設定表七欄位:基本資料、背景故事、性格原型、喜好厭惡、目標執念、說話方式、人際初始值;另含來源=原創/既有作品、建置狀態=候補/已建置)、`CharacterAlias`(正式名/暱稱/他人稱呼)。驗收:migrate 成功且可寫入讀出完整角色設定表。依據:§角色設定表(Character Sheet)、§作品資料結構「角色名冊是作品的戶口」。
- [x] **B-5 記憶資料表(M)**:定義 `EpisodicMemory`(內容、發生時間、情緒標籤類型+強度、提取次數、最後提取時間、來源=互動/離線生成/原作萃取、權重)、`SemanticMemory`(去情境化事實、對象)、`ProceduralRule`(情境→回應模式、權重)。驗收:三表可寫入讀出且 metadata 欄位齊全。依據:§核心機制設計 1「每筆記憶附 metadata:時間戳、情緒標籤、提取次數」。
- [x] **B-6 情緒與關係資料表(M)**:定義 `EmotionState`(各情緒維度值、最後更新時間,支援平靜/愉悅/低落/警戒/害羞/彆扭)、`Relationship`(intimacy 0~100、trust 0~100、first_met、last_interaction、interaction_count、關係階段)、`SentimentLedgerEntry`(date、event、weight,允許正負)。驗收:可完整重現 wiki§關係檔案資料結構的 YAML 範例欄位。依據:§關係檔案資料結構(示意)。
- [x] **B-V 階段驗證(XS)**:`npm run restart && npm run smoke -- B`(B.mjs 檢查:migrate 後資料表齊全、種子角色可由 API 讀出、關係與記憶可寫入讀出)。
> **實作記錄(B 群組)**:本機安裝到的 Prisma 為 **7.9.1**,架構與舊版本明顯不同,後續群組凡涉及資料層都要留意:
> - 連線設定分兩處:`prisma/schema.prisma` 只宣告 `provider`,實際 `DATABASE_URL` 由**根目錄 `prisma.config.ts`**的 `datasource.url` 讀取(`env()` helper),CLI 與執行期皆吃這份設定。
> - SQLite 需要 driver adapter 才能建立 `PrismaClient`:`@prisma/adapter-better-sqlite3` + `better-sqlite3`,`new PrismaClient({ adapter })`,不能再像舊版直接 `new PrismaClient()` 接 SQLite。
> - SQLite **可以**用 `enum`(底層存成 TEXT,型別檢查在 Prisma Client 層),已驗證過不需要再退化成 String + 註解。
> - `generator client { provider = "prisma-client" }`(非舊版 `prisma-client-js`)產出的是**可直接執行的 `.ts` 原始檔**(非預編譯 `.js`),且內部用 `import.meta.url`,因此消費端(`packages/db`)與其所有上游呼叫者(目前是 `apps/api`)都必須是 **ESM**(`package.json` 加 `"type": "module"`),否則 CommonJS 靜態 `require()` 一個純 ESM 套件會直接炸掉。`apps/api` 已改為 ESM 並驗證過 NestJS 在 ESM 下運作正常。
> - `prisma migrate dev` 不再像舊版自動跑 seed;要用 `npx prisma db seed`(seed 指令設定在 `prisma.config.ts` 的 `migrations.seed`,本專案用 `node prisma/seed.ts`,靠 Node 26 原生 TypeScript 執行,免裝 `tsx`/`ts-node`)。
> - Prisma 官方在 `.agents/skills/prisma-cli/references/agent-safety.md` 明文要求:`migrate reset`/`db push --force-reset`/`db push --accept-data-loss` 這類會清資料的指令,AI agent 執行前必須先跟使用者取得**逐字**同意,不得自行判斷「應該沒事」就跑。全清單往後所有群組都要遵守,不只是 B 群組。
> - `npx prisma init` 會在專案根目錄放一份 `.claude/skills`/`.agents/skills`/`.windsurf/skills`(symlink 到 `.agents/skills` 底下的實際內容)——這是 Prisma 官方隨 CLI 附的最新版操作手冊,比對任何舊版 Prisma 知識更可信,之後若對 Prisma CLI/Client API 行為有疑問,先查這裡再動手。
### C. 記憶子系統
- [x] **C-1 工作記憶(S)**:實作 session 對話上下文緩衝,帶 token 上限與溢位裁切策略(保留最近與高情緒段落)。驗收:超過上限時最舊的低情緒段落先被裁掉。依據:§腦區→聊天系統元件對照「工作記憶=對話上下文視窗,有 token 上限」。
- [x] **C-2 對話期只寫工作記憶(S)**:明確禁止對話流程中寫入長期記憶表,所有長期寫入只能由固化程序觸發。驗收:一輪對話後三張長期記憶表筆數不變。依據:§核心機制設計 1「對話中不即時寫長期記憶」。
- [x] **C-3 排程抽象層(S)**:定義 `JobQueue` 介面(`now` / `schedule(at)` / `every(cron)`),以 in-process 計時器實作 `InProcessJobQueue`,並在 DI 容器註冊;session 結束事件推入固化工作。驗收:排入 5 秒後的工作會準時執行。註記:R-4 換成 BullMQ 時只換實作、不動呼叫端。依據:技術選型「BullMQ:睡眠固化/離線事件/提醒排程」。
- [x] **C-4 睡眠固化程序(M)**:實作 `consolidate(sessionId)`——回顧整段對話,只有情緒強度高於門檻或被重複提及的內容才寫入情節/語意/程序記憶,並寫入完整 metadata。驗收:一段含「一件高情緒事件+數句閒聊」的對話固化後,只有高情緒事件進 `EpisodicMemory`。依據:§核心機制設計 1「Session 結束觸發睡眠固化」。
- [x] **C-5 遺忘與提取即改寫(M)**:固化程序中對「情緒強度低且長期未被提取」的記憶降權或刪除;每次檢索命中即更新提取次數與最後提取時間。驗收:模擬時間推進後低權重舊記憶被清除,常被提取者保留。依據:§核心機制設計 2「記憶遺忘(自然衰減)」。
- [x] **C-6 記憶檢索器(M)**:定義 `MemoryRetriever` 介面並實作關鍵字版本,排序權重=關鍵字相關度+時間近因+情緒權重+(E-6 補上的)關係對象加權。驗收:查詢命中相關情節記憶且排序符合權重設計。註記:R-2 會加上 pgvector 語意檢索實作。依據:技術選型「pgvector:記憶語意檢索」。
- [x] **C-V 階段驗證(XS)**:`npm run restart && npm run smoke -- C`(C.mjs:模擬一段對話 → 觸發固化 → 驗證高情緒事件入庫、瑣事未入庫、檢索可命中、提取次數遞增)。
> **實作記錄(C 群組)**:
> - 記憶引擎全放在 `apps/api/src/memory/`(NestJS module),情緒標記(`emotionTag`/`emotionIntensity`)在此階段由**呼叫端提供**,不是自己判斷——因為執行順序圖 C/D/E 是同層平行,C 不能依賴 D 尚未建置的情緒標記器;F 群組整合時才會接上 D 的真實輸出。
> - `POST/GET /memory/:characterId/sessions/:sessionId/messages`、`.../consolidate`、`GET /memory/:characterId/retrieve`、`POST /memory/forgetting-sweep` 這幾個端點目前是**工程內部驗證用**,不是使用者可見 API;H-1「`POST /chat/:characterId` 走完整管線」「`POST /session/:id/end` 觸發睡眠固化」上線後,這些端點的邏輯會被納入正式管線,屆時再決定是否保留、改名或整組移除。
> - 高情緒門檻(`HIGH_EMOTION_THRESHOLD = 0.6`)與工作記憶 token 上限(`DEFAULT_TOKEN_LIMIT = 200`,粗略以「字元數 / 2」估算,非真實 tokenizer)都定義在 `apps/api/src/memory/constants.ts` 與 `working-memory.service.ts`,D 群組的情緒狀態機若要共用同一門檻語意,應該從這裡匯入而不是各自定一份。
> - `JobQueue`(`now`/`schedule(at)`/`every(cron)`)介面在 `apps/api/src/memory/job-queue.ts`,`InProcessJobQueue` 用 `setTimeout`/`node-cron` 實作;`every()` 目前唯一的呼叫者是每小時跑一次的遺忘清掃(`memory.module.ts` 的 `onModuleInit`)。J 群組的提醒排程應直接複用同一個 `JOB_QUEUE`,不要另外自己接計時器。
### D. 情緒子系統
- [x] **D-1 情緒標記器(S)**:實作 `EmotionTagger` 介面與規則版實作(關鍵字+輸入特徵+角色觸發閾值),輸出情緒類型與強度分數。驗收:正向/負向/衝突語氣輸入分別得到對應標記。依據:§聊天系統架構圖「情緒標記器:為輸入評估情緒強度」。
- [x] **D-2 時間衰減(S)**:以半衰期函數隨時間回歸平靜,衰減速度由角色參數決定;跨 session 保留殘留情緒。驗收:模擬經過數小時後情緒值回落,重新開啟 session 仍讀得到殘留。依據:§情緒狀態機「跨 session 保留情緒殘留……但隨時間衰減」。
- [x] **D-3 情緒→回應風格輸出(S)**:把當前情緒轉為回應參數(語氣、句長、主動性),提供給 F 群組的上下文組裝器。驗收:愉悅時參數指向「多話」、低落時指向「簡短」。依據:§情緒狀態機「情緒狀態影響:回覆語氣、用詞選擇、主動性」。
- [x] **D-4 情緒狀態機(M)**:實作平靜/愉悅/低落/警戒的轉移規則(正向互動、負向事件、偵測衝突語氣、確認無威脅、衝突持續),並加入害羞與彆扭兩個狀態(供 G-4 可愛度行為使用)。驗收:依 wiki 狀態圖的每條轉移邊都有對應測試通過。依據:§情緒狀態機 stateDiagram、§可愛度設計「情緒引擎新增『羞』狀態」。
- [x] **D-V 階段驗證(XS)**:`npm run restart && npm run smoke -- D`(D.mjs:連續負向輸入→進警戒;模擬時間推進→回平靜;跨 session 讀回殘留)。
> **實作記錄(D 群組)**:
> - 情緒引擎放在 `apps/api/src/emotion/`。`EmotionState` 的 6 個浮點欄位(calm/joy/sad/alert/shy/grumpy)**不是各自獨立累積**,而是「當下只有一個主導情緒+其強度」:`dominantState()` 取非平靜維度中數值最高且超過門檻(5)者,狀態轉移發生時會把其餘維度清零、只把目標維度設成新強度。這是刻意的設計取捨——避免連續多種訊號各自累加造成無來源依據的隱性轉移,讓 D-4 的邊全部可預期、可測試。之後 G 群組若要疊加「性格參數影響情緒外顯度」,建議在讀出 `dominantState` 之後的顯示層做縮放,不要改動這裡的儲存邏輯。
> - D-4 狀態機**只實作 wiki 狀態圖 + 可愛度設計明訂的邊**(`apps/api/src/emotion/emotion-state-machine.ts`),刻意不外推「愉悅/低落/害羞」之間的直接互轉——這些狀態目前只能先衰減回平靜,才能被新訊號帶往別的狀態。害羞(被稱讚觸發)與彆扭(被忽略/比較等小型負向觸發,被哄後快速恢復)是本群組依「可愛度設計」章節新增,wiki 原始狀態圖沒有畫出來。
> - 時間衰減用半衰期公式(預設 2 小時,`processInput`/`getState` 皆可傳 `halfLifeMs` 覆寫),衰減與情緒讀寫都走資料庫的 `EmotionState.updatedAt`,天生跨 session/跨行程持久,不需要额外的殘留儲存機制。
> - `GET/POST /emotion/:characterId`、`.../input`、`.../response-style` 同樣是**工程內部驗證用端點**,性質與 C 群組的 `/memory/*` 一致,等 F/H 群組整合對話管線時再決定去留。
> - D-1 的關鍵字表與觸發優先序(衝突 > 稱讚/害羞 > 輕度負向/彆扭 > 一般負向 > 正向)、D-3 的回應風格對照表都只是 Mock 階段的規則版本;R-3 接上真實 LLM 後,這兩塊要嘛保留作為安全網、要嘛被模型自身的語氣調節取代,屆時再一併決定。
### E. 關係子系統
- [x] **E-1 親密度與信任讀寫(XS)**:實作關係檔案的建立(未知對象預設「禮貌+防備」初始值)與讀寫服務。驗收:新對象首次互動自動建檔。依據:§關係如何影響對話行為「未知 → 建立新關係檔案,預設:禮貌+防備」。
- [x] **E-2 關係帳本與負向偏誤(S)**:互動事件寫入 `SentimentLedgerEntry`,負向事件權重放大係數可由角色參數調整。驗收:同等級正負事件各一次後,淨值為負。依據:§關係如何被大腦儲存與更新「負向偏誤:一次背叛抵銷多次善意」。
- [x] **E-3 親密度分層(S)**:實作 陌生 0-19 /認識 20-39 /朋友 40-59 /摯友-曖昧 60-79 /羈絆 80-100 五階段與跨階解鎖旗標。驗收:跨越門檻時發出可被其他子系統訂閱的事件。依據:§動漫式關係進展(好感度系統)。
- [x] **E-4 關係時間衰減(S)**:久未互動自動降親密度,重逢時開場語氣可讀取「久未見」旗標。驗收:模擬長時間未互動後親密度下降且旗標為真。依據:§映射到聊天系統:關係模型「關係衰減:時間衰減函數」。
- [x] **E-5 關係加權檢索接點(S)**:把「與當前對象相關」納入 C-6 檢索權重。驗收:與 A 對話時 A 相關記憶排序優先於同分數的無關記憶。依據:§與其他子系統的整合「關係 × 記憶」。
- [x] **E-6 意圖推測(M)**:依對象歷史互動模式解讀當前訊息(同一句話,高親密度判為玩笑、低親密度判為冒犯),輸出解讀標記供生成層使用。驗收:相同輸入在高/低親密度下得到不同解讀標記。依據:§映射到聊天系統:關係模型「心智理論 → 意圖推測」。
- [x] **E-V 階段驗證(XS)**:`npm run restart && npm run smoke -- E`(E.mjs:帳本累積、負向偏誤、分層跨越事件、衰減、關係加權檢索、意圖推測差異)。
> **實作記錄(E 群組)**:
> - 關係引擎放在 `apps/api/src/relationship/`。E-3 跨階事件用 `@nestjs/event-emitter`(`EventEmitterModule.forRoot()` 已在 `app.module.ts` 註冊),`RelationshipService` 只負責 `emit`,實際訂閱者是獨立的 `StageChangeLogService`(`@OnEvent` 訂閱),兩者互不直接呼叫——之後 I/J 群組要訂閱「關係跨階」時比照這個模式加一個新的訂閱者即可,不要改 `RelationshipService` 本身。
> - E-5 為此新增了 `EpisodicMemory.relatedUserId`(`prisma/migrations/20260813022124_add_episodic_memory_related_user`),讓記憶可以標記「與哪位使用者相關」;`KeywordMemoryRetriever.retrieve()` 簽名因此從 `(characterId, query, limit?)` 改成 `(characterId, query, options?: { limit?, relatedUserId? })`——C 群組完成後才加的參數,之後任何呼叫端都要用新簽名。
> - 親密度只用一條「日常互動」通道累積(`recordInteraction`,預設每次 +2),信任則只透過 `SentimentLedgerService.recordEvent` 的負向偏誤事件調整;兩者刻意分開更新,因為 L 群組的「心動值」也會是第三條獨立通道,現在先把「親密度/信任」两條分清楚,之後加心動值不會互相污染。
> - **重要教訓(本機環境限定,務必記住)**:`prisma migrate dev`/`generate`/`resolve` 這類 CLI 指令在本機環境會呼叫一個網路 checkpoint telemetry(`runCheckpointClientCheck`),此環境下該呼叫**不穩定**,曾多次造成指令看似「卡住無輸出」長達 20~30 秒以上。之後所有 Prisma CLI 呼叫一律加上 `CHECKPOINT_DISABLE=1` 環境變數(例:`CHECKPOINT_DISABLE=1 npx prisma migrate dev ...`),可完全避開這個網路呼叫。
> - **更重要的教訓**:本群組實際發生過一次「`migrate dev` 被判定卡住而中止,結果是 migration 已經對部份資料表執行完成」的部分套用(SQLite 對每張 `RedefineTable` 似乎是個別提交,不是整批一個交易)。之後若怀疑某次 `migrate dev`/`migrate deploy` 執行到一半被中斷:**先跑 `CHECKPOINT_DISABLE=1 npx prisma migrate status` 確認 `_prisma_migrations` 是否有 `finished_at` 為空的紀錄**,若有,需要用 `better-sqlite3` 直接檢查每張受影響資料表的 `sqlite_master.sql`(比對是否已含目標 schema/有無殘留的 `new_<table>` 暫存表),**手動補完尚未套用的部分**(可從 `migration.sql` 擷取對應片段執行),最後才下 `prisma migrate resolve --applied <migration_name>` 讓追蹤表與實際狀態一致。絕不可以在部分套用的狀態下直接重跑整份 `migrate dev`/`deploy`,也絕不可以不經確認就對 `_prisma_migrations` 動手。
### F. 對話生成管線與 LLM Provider 抽象
- [x] **F-1 LLMProvider 介面(S)**:定義 `generate(context)` 與 `stream(context)`,輸入為組裝好的上下文物件(人設、情緒狀態、檢索記憶、關係參數、對話歷史),輸出為含動作描寫標記的回應結構。驗收:型別定義於 `packages/shared` 且 api 依賴介面而非實作。依據:§LLM Provider 抽象層「切換 Provider 不動引擎任何一行」。
- [x] **F-2 Provider 切換與 ClaudeProvider 空殼(S)**:以環境變數 `LLM_PROVIDER=mock|claude` 決定注入哪個實作,`ClaudeProvider` 先拋「尚未實作(R-6)」。驗收:設為 `claude` 時啟動即以 ERR log 明確告知未實作。依據:§LLM Provider 抽象層流程圖。
- [x] **F-3 輸出過濾層(S)**:實作前額葉抑制層——安全檢查、語氣調節、角色禁則詞彙過濾,位於 Provider 之後、回覆之前。驗收:含禁則詞的模板輸出被攔截或改寫。依據:§腦區對照「前額葉(抑制)=輸出過濾」。
- [x] **F-4 雙速通道(S)**:高頻固定問候與明確危險輸入走快速通道(直接套用程序記憶模式),其餘走完整流程。驗收:快速通道回應不觸發記憶檢索(以計數驗證)。依據:§核心機制設計 4「雙速回應(快慢通道)」。
- [x] **F-5 行為強化迴路(S)**:使用者明確稱讚 → 對應程序記憶模式加權;使用者糾正 → 原模式降權並以修正版取代;重複命中的「情境→回應」自動下沉為慣例。驗收:稱讚後同情境優先選用該模式。依據:§核心機制設計 5「行為強化迴路」。
- [x] **F-6 上下文組裝器(M)**:把人設、當前情緒、檢索到的記憶、關係參數、對話歷史組裝成統一上下文物件,並記錄「本次注入了哪些記憶」供除錯。驗收:一次對話可輸出完整組裝內容快照。依據:§LLM Provider 抽象層「上下文組裝邏輯先在 Mock 期打磨定型」。
- [x] **F-7 MockProvider(M)**:依「性格原型 × 情緒狀態 × 親密度」從模板庫選填回應,支援固定 seed 產生可重現輸出,並輸出動作描寫標記(如 `*臉紅撇過頭*`)。驗收:同 seed 兩次輸出完全相同;不同情緒/親密度輸出不同模板。依據:§LLM Provider 抽象層「MockProvider 行為」。
- [x] **F-V 階段驗證(XS)**:`npm run restart && npm run smoke -- F`(F.mjs:同 seed 可重現、情緒/親密度影響輸出、快速通道不檢索、禁則被過濾)。
> **實作記錄(F 群組)**:
> - 對話引擎放在 `apps/api/src/llm/`。`GenerationContext`/`LLMProvider` 型別刻意沒有放進 `packages/shared`——這是 api 內部引擎的組裝結果,不是 web/mobile 需要的資料形狀,跟 C/D/E 的模式一致(引擎邏輯留在 apps/api,只有跨端都要用的資料形狀才進 packages/shared)。
> - `MockProvider` 的模板庫(`template-library.ts`)目前**只有元氣一種原型**(種子角色用的),其餘傲嬌/冷淡/天然呆/大小姐/三無都還沒有模板,會 fallback 到通用預設句。**G 群組建立六原型參數表時,必須回來補齊 `TEMPLATE_LIBRARY` 其餘五種原型**,否則那五種角色對話會全部長得一樣。
> - F-4 雙速通道的「危險輸入」偵測目前是關鍵字比對(`想死`/`自殺`/`傷害自己`/`活不下去`),命中後給的是固定安全回覆(含 1995 生命線),**沒有另外通知任何人或記錄告警**——這只是 Mock 階段的最低限度安全網,真正的危機處理流程(例如是否要通知使用者填寫的緊急聯絡人)不在本次清單範圍內,若後續要做需另外立項、不要預設已經涵蓋。
> - F-5 的行為強化迴路目前只認「完全相同的 `responsePattern` 字串」為同一個模式;`applyCorrection` 修正版會**繼承**舊模式修正前的權重(而非重新從 1 開始),確保修正後排序上一定領先,避免降權後打平的問題(實作時發現的真實 bug,已修正並補上對應測試)。
> - F-2 的 `LLM_PROVIDER=claude` 檢查是在 NestJS 的 `useFactory` 裡直接判斷並 log,沒有另外用 `OnModuleInit`——因為 factory 本身就是在啟動期被 DI 容器呼叫一次,效果等價但更簡單。R-3 真的接上 Claude API 時,把 `ClaudeProvider` 內部的 `throw` 換成真實呼叫即可,`llm.module.ts` 的切換邏輯不需要動。
### G. 角色人格層
- [x] **G-1 性格原型參數表(S)**:建立 傲嬌/冷淡/天然呆/元氣/大小姐/三無 六原型的參數組(情緒觸發閾值、情緒外顯度、信任成長速度、特徵行為旗標),存為可版本化的設定檔並掛到 `Character`。驗收:切換原型後同一輸入產生不同情緒與語氣參數。依據:§性格原型 → 引擎參數對照。
- [x] **G-2 語言風格層(S)**:實作第一/第二人稱、口癖、語尾、稱呼系統(依親密度切換:您/同學 → 名字 → 暱稱)與禁則清單,作為輸出層後處理。驗收:親密度跨階後稱呼自動改變。依據:§語言風格層(輸出模板)。
- [x] **G-3 外顯函數(S)**:實作內部親密度與外顯表現的轉換(傲嬌為反向表達,「傲嬌值」=內外差值,隨內部值升高而縮小)。驗收:傲嬌角色內部親密度上升時,外顯敵意先升後降。依據:§核心原則「傲嬌不是沒有好感,而是好感的外顯函數是反向的」。
- [x] **G-4 反差萌與稀有度控制(S)**:反差行為=性格原型的例外規則,觸發條件(親密度門檻+情緒狀態+機率)滿足才發生,觸發後進入冷卻期,且永不常態化。驗收:冷卻期內同類反差不再觸發。依據:§反差萌的參數化「稀有度規則」。
- [x] **G-5 角色建立流程(S)**:提供由角色設定表產生「初始語意記憶(關於自己的事實)+初始情節記憶(背景故事,帶情緒標記)+情緒參數+關係初始值」的建立指令。驗收:建立新角色後三類初始資料齊備。依據:§分層設計:引擎與角色分離的資料流。
- [x] **G-6 可愛度行為機制(M)**:實作害羞、撒嬌(親密度≥60)、鬧彆扭(被哄後快速恢復)、吃醋(偵測第三者好感訊號)、記住小事(使用者瑣事額外加權並日後主動提起)、笨拙的努力、稱呼進化、專屬揭露(信任≥80 解鎖深層記憶並明示「只跟你說過」)八種行為的觸發條件與節奏控制(撒嬌頻率克制、彆扭不超過兩三輪)。驗收:每種行為都有對應的觸發測試通過。依據:§具體行為機制與引擎實作、§可愛的節奏控制。
- [x] **G-V 階段驗證(XS)**:`npm run restart && npm run smoke -- G`(G.mjs:六原型參數生效、稱呼進化、反差冷卻、可愛行為觸發條件)。
> **實作記錄(G 群組)**:
> - 人格層放在 `apps/api/src/personality/`,`ARCHETYPE_PARAMS`(`archetype-params.ts`)是六原型參數的唯一來源,D 群組的 `EmotionService.processInput` 與 E 群組的 `SentimentLedgerService.recordEvent` 都改成**自動從角色的 `personalityArchetype` 查表**帶入預設值(呼叫端仍可明確覆寫)——之後若要調整某個原型的敏感度或信任成長速度,只改這一份表,不要在 D/E 的程式碼裡另外硬寫數字。
> - 實作 G-1 時發現一個真實落差:原本「情緒觸發閾值」只會縮放觸發後的**強度**,不會影響「是否觸發」本身(因為 CALM 狀態收到任何非 CALM 訊號就會轉移,跟閾值無關)。這樣「難觸發」的原型(冷淡/三無)其實還是每次都會有反應,只是反應小一點,不符合「難觸發」字面意思。已在 `emotion.service.ts` 加入 `MIN_TRIGGER_INTENSITY`(0.25)門檻:強度低於門檻視同沒有訊號、完全不觸發轉移,這樣「冷淡對弱刺激真的沒反應」才是真的。**F/H 群組串接對話管線時,如果角色對某些訊息「完全沒反應」,這是刻意設計,不是 bug。**
> - `MockProvider` 的模板庫仍然**只有元氣一種原型**——本群組的時間全部花在引擎參數化(G-1~G-6)上,沒有回頭補其餘五種原型的對話模板。**H 群組要做網頁對話介面時,若想展示傲嬌/冷淡/天然呆/大小姐/三無的角色,必須先回 `apps/api/src/llm/template-library.ts` 補上對應模板,否則這五種原型講出來的話會跟元氣長得一樣(走 DEFAULT_TEMPLATES)。**
> - G-3 的外顯函數目前**只有傲嬌一種特殊轉換**(反向表達),其餘五個原型 `computeExpressedIntimacy` 直接回傳內部值本身(內外一致)。「情緒外顯度」(`expressiveness` 參數)目前只存在參數表裡、還沒有任何地方真的拿來縮放輸出——這是刻意留給 **O 群組(立繪)** 的:O-4「依角色外顯度縮放變化幅度」會是第一個真正消費 `expressiveness` 這個數字的地方。
> - G-4 反差萌新增了 `ContrastTriggerLog` 資料表(`prisma/migrations/20260813031615_add_contrast_trigger_log`),只記錄「角色本身」的觸發時間(不分對象),因為 wiki 原文描述的是角色自己的稀有行為預算,不是特定關係的。
> - G-6「記住小事」修改了 C-4 的 `MemoryConsolidationService`:現在使用者訊息即使情緒強度不足、也沒有被重複提及,只要命中瑣事關鍵字(`我喜歡`/`我不喜歡`/`我最近`等)就會額外寫入 `SemanticMemory`。這是對 C 群組既有邏輯的**擴充**而非另開一條路,往後任何人修改固化規則時要記得這條分支還在。
> - G-6「笨拙的努力」(項目 6)目前只是一個接受外部旗標的通過函式(`shouldShowClumsyEffort(isWeakArea)`),因為「角色弱項」與「任務執行」的資料結構屬於 **J 群組(委託子系統)** 尚未建立的範疇。**J 群組實作委託任務時,必須自己判斷什麼情境算「弱項」並把旗標傳進來,這裡不會自動生效。**
### H. 網頁對話介面(第一個可實際使用的垂直切片)
- [x] **H-1 對話 API 與 session 生命週期(S)**:`POST /chat/:characterId` 走完整管線(標記情緒 → 檢索記憶 → 組裝 → 生成 → 過濾),`POST /session/:id/end` 觸發睡眠固化。驗收:一輪完整對話回傳回應、情緒狀態與親密度變化。
- [x] **H-2 情緒晶片與親密度顯示(S)**:對話畫面顯示當前情緒(愉悅/害羞/彆扭…)與親密度數值。驗收:畫面數值與 API 回傳一致並即時更新。依據:主視覺與資產§電腦網頁 mockup 的「親密度 62」「愉悅」晶片。
- [x] **H-3 動作描寫標記渲染(S)**:把 `*…*` 標記以暮空紫小字呈現,與台詞區分。驗收:含動作描寫的回應在畫面上樣式正確。依據:主視覺與資產 mockup `.bubble .act` 樣式。
- [x] **H-4 心跳波形母題(S)**:載入動畫為波形由左至右畫出、親密度以波形振幅呈現、通知紅點為心跳脈動;尊重 `prefers-reduced-motion`。驗收:三處母題皆可見且降低動態偏好下不動畫。依據:主視覺與資產§視覺母題。
- [x] **H-5 對話頁版面(M)**:電腦版雙欄(左對話流、右大幅立繪區,先以佔位圖形);行動版單欄、立繪半身像置頂佔 40% 為背景層、對話流覆蓋其上;同一份 RWD 程式。驗收:桌面與 720px 以下寬度各自呈現正確版面。依據:§三種載體的版面。
- [x] **H-V 階段驗證(S)**:`npm run restart && npm run smoke -- H`(H.mjs 端到端:送訊息 → 收回應 → 情緒與親密度變化可見 → 結束 session 觸發固化),並實際開啟瀏覽器操作一次確認畫面無誤。
> **實作記錄(H 群組)**:
> - `POST /chat/:characterId`(`apps/api/src/chat/`)才是正式對外端點;F 群組的 `DialogueService.handleMessage` 補上了兩行呼叫(`EmotionService.processInput`、`RelationshipService.recordInteraction`),讓每輪對話會**真的**標記情緒、累積親密度,而不是只讀現有狀態——F 群組當時的 `/dialogue/*` 端點刻意沒做這件事(見 F 群組實作記錄),H 群組把它補上。往後任何人再動 `DialogueService.handleMessage`,記得這兩行是新對話語意變化的入口,不要誤刪。
> - NestJS 這邊加了 `app.enableCors({ origin: WEB_ORIGIN ?? "http://localhost:3100" })`(`main.ts`)——這是本清單第一次有瀏覽器直接打 api,之前所有群組都只靠 curl/smoke test,不會撞到 CORS。
> - 前端色票整份換成 wiki「主視覺與資產」頁完整 HTML 提案裡的 token 組(`--bg`/`--bg-soft`/`--ink`/`--ink-soft`/`--accent`/`--accent-bright`/`--violet`/`--line`/`--card`/`--chip-bg`/`--wave`/`--frame`/`--shadow`,含官方給的深色模式數值),比 A-5 當時只做的四色簡化版完整很多;`--color-heartbeat-pink`/`--color-dusk-purple`/`--background`/`--foreground` 留著當作 A-5 首頁的別名,沒有刪除舊頁面的相依。
> - **重要**:apps/web 用 Next.js 的 `moduleResolution: "bundler"`,相對匯入**不可以**加 `.js` 副檔名(跟 apps/api 的 NodeNext 慣例相反,那邊要求一定要加)。這次寫元件時把後端習慣帶過來,加了 `.js` 結果 Turbopack 直接找不到檔案、整個 web 健康檢查逾時 60 秒。**以後在 apps/web 底下新增檔案,相對匯入一律不要加副檔名;只有 apps/api、packages/shared、packages/db 底下才需要加 `.js`。**
> - 對話頁走固定的 `CHARACTER_ID`/`USER_ID`(種子角色與種子使用者),因為角色選擇、登入機制都還沒有 wiki 依據(見「需人工確認」清單);`sessionId` 用 `crypto.randomUUID()` 在元件掛載時產生一次。
> - 心跳波形(`HeartbeatWave` 元件)用同一份 SVG/CSS 動畫邏輯同時服務首頁(A-5)與對話頁,振幅由親密度(0~100)換算縮放係數 0.4~1.5;`prefers-reduced-motion` 已在 `globals.css` 用 media query 關閉動畫,沒有另外寫 JS 判斷。
> - **驗證方式與環境限定備註**:這台機器目前是共用的重載主機(`uptime` 曾量到 load average 39+,遠超這台機器的核心數,來自其他無關的並行 session),單一 API 請求偶爾會被系統排擠到 30~50 秒——**這不是本群組程式碼的效能問題**,同一請求在負載降下來後可以在 3 秒內完成。之後如果又遇到「重啟或 smoke test 突然變得異常慢」,先用 `uptime` 確認 load average 是否異常,不要急著去改程式碼。
> - 本機沒有 GUI 瀏覽器,用 Playwright(暫裝在 scratchpad,非專案相依)+ headless Chromium 驗證畫面;headless shell 需要 `libasound.so.2`(本機沒有 root 權限跑 `apt-get install`),改用其他 session 已解壓好的 `.deb` 內容夾搭配 `LD_LIBRARY_PATH` 繞過,沒有動到系統套件。畫面截圖裡中文顯示為方塊字,是測試用無頭瀏覽器缺中文字型,**不是程式或 CSS 的問題**(DOM 文字內容本身是正確的繁體中文,実際使用者瀏覽器有系統字型即可正常顯示)。
### I. 生活作息與離線生活
- [x] **I-1 作息表資料結構(S)**:平日/週末/假期各一份時段表,欄位含各時段狀態(睡眠/忙碌/半忙碌/空閒)、固定行程、特殊日(考試週、生日、紀念日)。驗收:可為種子角色寫入一份高中生作息表。依據:§作息表設計表格。
- [x] **I-2 時段狀態→回應可用度(S)**:空閒正常回應、半忙碌簡短、忙碌延遲或不回並事後補一句、睡眠不回、剛醒/睡前加上迷糊演出。驗收:模擬各時段輸入得到對應行為。依據:§作息如何影響對話。
- [x] **I-3 時間感知(S)**:角色知道現在幾點、星期幾、季節,開場語與話題隨之調整。驗收:不同時段開場語不同。依據:§作息如何影響對話「時間感知」。
- [x] **I-4 破例規則(S)**:深夜還陪你聊(親密度高+使用者情緒低落)、上課偷回訊息(親密度極高+緊急)、為你調整行程(羈絆級)三種破例,且必須稀有。驗收:條件不足時不破例,條件滿足時破例並記入關係帳本。依據:§破例即訊號:作息 × 親密度。
- [x] **I-5 作息驅動情緒基線(S)**:社團剛結束「累」、放假日基線偏高等日內波動。驗收:同一輸入在不同時段得到不同情緒起點。依據:§作息如何影響對話「狀態殘留」。
- [x] **I-6 離線事件生成(M)**:依時段+性格+近期劇情,於下次對話開始時按需生成合理日常小事,寫入低權重情節記憶並作為話題來源;生成內容須符合作息表與角色設定。驗收:隔一段時間再對話,角色會主動提起今天發生的事,且事件不違反其作息。依據:§離線生活:不在線時她在活著。
- [x] **I-V 階段驗證(XS)**:`npm run restart && npm run smoke -- I`(I.mjs:睡眠時段不回、忙碌延遲、破例條件、離線事件生成與一致性約束)。
> **實作記錄(I 群組)**:
> - 作息引擎放在 `apps/api/src/schedule/`。時間判斷(星期幾/幾點/今天日期)一律走 `time-utils.ts` 的 `Intl.DateTimeFormat({ timeZone: "Asia/Taipei" })`,不吃伺服器所在時區——之後任何跟「現在幾點」有關的邏輯都應該重用這裡的 `taipeiParts`/`minutesOfDay`/`isSameTaipeiDate`/`startOfTaipeiDay`,不要另外用 `Date.getHours()` 之類的本地時間 API(那會受執行環境時區影響)。
> - **關鍵設計:沒有作息表的角色一律視為 NORMAL(正常回應)**,不會因為缺資料被誤判成忙碌或睡眠。這是刻意的相容性設計——`seed-character-genki`(H 群組所有測試依賴的種子角色)**沒有**掛作息表,所以 H 群組的冒煙測試不受時段影響、任何時間執行都會是 NORMAL。**日後若要幫種子角色掛上作息表,必須同時檢查 H.mjs 是否還會在忙碌/睡眠時段被跑到**,否則會間歇性炸掉;比較安全的做法是只給新建的測試角色掛作息表(本群組的 `smoke-i-character` 即是如此,測試完會整個角色一起砍掉,schedule 隨 cascade 一起消失)。
> - `DialogueService.handleMessage` 現在的執行順序是:**離線事件生成(I-6)→ 情緒基線+標記情緒(I-5/沿用 D)→ 累積親密度(沿用 E)→ 作息可用度判定(I-2)→ 不符合則嘗試破例(I-4)→ 依可用度決定要不要真的生成回覆**。`HandleMessageResult` 因此多了 `availability` 與 `exceptionType` 兩個欄位,`context` 在 `NO_REPLY`(睡眠且未破例)時會是 `null`——**任何後續程式碼(包含 H 群組已有的網頁前端)如果要讀 `result.context`,都要先檢查是否為 null**,目前 `ChatView.tsx` 還沒有處理這個情況(沒有作息表的種子角色不會走到這條路徑,所以現在不會炸,但如果之後角色掛了作息表,網頁前端在忙碌/睡眠時段會直接對 null 解構出錯,需要之後的群組或前端調整時補上防呆)。
> - 修正一個真實 bug:G-5 角色建立流程原本把背景故事那筆初始情節記憶的 `source` 設成 `OFFLINE_GENERATED`,跟 I-6「今天是否已生成過離線事件」的判斷(同樣查 `source: OFFLINE_GENERATED`)**共用同一個標記**,導致新角色建立當天,I-6 會誤判「今天已經生成過離線事件」而跳過真正的日常小事生成。已改成 `SOURCE_EXTRACTION`。**M 群組之後如果也要用 `SOURCE_EXTRACTION` 標記原作萃取的記憶,要注意這個值現在也被 G-5 拿來標記「角色設定表生成的背景故事」,语意上共用是合理的(都是「非使用者互動、非離線日常」的來源),但如果 M 群組有自己的「今天是否已處理過某章節」之類的判斷,同樣不要跟 `source` 欄位的既有語意衝突。**
> - I-4 的三種破例都會呼叫既有的 `SentimentLedgerService.recordEvent` 記一筆關係帳本(正向事件,權重 5),不是另外開一張表存「破例事件」;破例本身的稀有度冷卻則是獨立的 `ScheduleExceptionLog` 表(不跟關係帳本的時間戳綁在一起)。
> - I-2「剛醒/睡前的迷糊演出」目前只有最低限度的實作(在回覆前面加上「……」),完整的口吻變化(語尾拖長、錯字率上升)留給模板庫日後擴充,做法與 G 群組「模板庫只有元氣一種原型」的已知限制一致。
### J. 委託子系統
- [x] **J-1 任務資料表與解析(S)**:`Task`(類型=單次/週期/條件/查詢整理/代辦追蹤、觸發時間或條件、內容、狀態),並實作自然語句解析成任務。驗收:「三點提醒我開會」建立正確的單次提醒。依據:§委託類型表。
- [x] **J-2 接案演出層(S)**:依性格原型輸出接案台詞(傲嬌嘴硬、元氣熱情、冷淡一個「嗯」),但引擎層必定接受任務——拒絕只是演出。驗收:六原型各有對應台詞且任務皆成功建立。依據:§接案的角色演出「該接的最後都會接」。
- [x] **J-3 觸發演出(S)**:提醒觸發時語氣隨作息(深夜壓低、早晨迷糊),內容帶記憶前因後果(「資料你昨天說還沒做完,帶了嗎?」)。驗收:觸發訊息含相關記憶引用。依據:§提醒的觸發演出「提醒不是鬧鐘,是記得前因後果的人在提醒你」。
- [x] **J-4 逾期追擊(S)**:使用者無反應時依性格追擊(元氣連環、冷淡隔十分鐘補一句、傲嬌口是心非再傳一次)。驗收:模擬無回應後產生符合原型的追擊訊息。依據:§提醒的觸發演出「逾期追擊」。
- [x] **J-5 委託回饋迴路(S)**:每次委託與完成寫入關係帳本(正向事件),被感謝提升情緒、被忽略產生小失落;重複同類委託形成默契旗標。驗收:完成委託後親密度與情緒皆有變化。依據:§任務與記憶、關係的迴路。
- [x] **J-6 主動關照(S)**:偵測使用者待辦訊號並追蹤、作息交叉比對(常熬夜 → 到點主動出現),且頻率克制、不重複同一句。驗收:連續觸發時受頻率上限抑制。依據:§主動關照:沒被委託的提醒。
- [x] **J-7 排程器可靠性(M)**:走 C-3 的 `JobQueue`,時間到必觸發(角色設定的迷糊只能表現在演出,不得真的忘記);服務重啟後未觸發的任務仍會被重新排入。驗收:建立提醒後重啟服務,時間到仍準時觸發。依據:§分層:任務引擎與角色演出「排程與執行走確定性系統」。
- [x] **J-V 階段驗證(XS)**:`npm run restart && npm run smoke -- J`(J.mjs:一分鐘後提醒必觸發、重啟後不遺失、演出符合原型、逾期追擊)。
> **實作記錄(J 群組)**:
> - 委託子系統放在 `apps/api/src/task/`,嚴格遵守「執行是引擎責任、演出是角色責任」的分層:`TaskService`/`TaskSchedulerService`/`TaskTriggerService` 這條主線絕不因性格而跳過或延遲觸發;`task-performance.ts` 純粹是各原型的台詞庫,不影響任何狀態轉移邏輯。
> - **J-1 自然語句解析**(`task-parser.ts`)只處理「`<時間片語>提醒我<內容>`」這個句型,中文數字(一~十二)與阿拉伯數字時刻皆可辨識,並用「取最接近的未來時刻」規則消解無上下午標記的模糊時刻(例如現在是早上 8 點時,「三點」會解析成當天下午 3 點;現在是晚上 8 點時,「三點」會解析成隔天凌晨 3 點)。其餘四種委託類型(週期/條件/查詢整理/代辦追蹤)目前只有資料結構與直接建立 API(`POST /task/:characterId`,可指定 `type`),還沒有對應的自然語句解析規則——**未來若要支援「每天提醒我」「下雨提醒我帶傘」之類的句型,需要在 `task-parser.ts` 擴充,目前只有 `parseReminderRequest` 這一個解析函式**。
> - **J-7 排程器可靠性的關鍵機制**:`InProcessJobQueue` 的計時器只存在於記憶體,程序重啟就會全部消失,因此 `TaskSchedulerService` 實作了 `OnModuleInit`:每次應用程式啟動時,都會重新掃描資料庫裡所有 `status=PENDING` 且有 `triggerAt` 的任務並重新掛回 `JobQueue.schedule()`;若 `triggerAt` 已經是過去(服務重啟期間錯過的),`InProcessJobQueue.schedule()` 內部的 `Math.max(0, ...)` 會讓它幾乎立刻補觸發。**冒煙測試裡的 J-7 是唯一一個會真的呼叫 `npm run restart`(用 `spawnSync` 實際重啟整組 api/web 行程)的測試**,藉此證明這個機制不是紙上談兵——手動驗證時也是先建立一個 8 秒後觸發的任務、立刻重啟服務、再等待確認狀態變成 `TRIGGERED`。**因為這樣,J.mjs 執行時間明顯比其他群組長(含一次完整的建置+重啟+健康檢查),這是預期行為,不是效能異常。**
> - **重啟瞬間的 keep-alive 連線陷阱**:`npm run restart` 執行期間,Node 內建 `fetch`(undici)可能持有指向「舊行程」的 keep-alive socket;舊行程被殺掉時,下一次重用這條 socket 送出的請求會直接丟出 `fetch failed`(cause 是 `SocketError: other side closed`)。J.mjs 在重啟後的輪詢迴圈裡把這類錯誤當成「服務還沒就緒」重試,而不是直接讓測試失敗——**這不是本群組特有的問題,任何在服務重啟前後緊接著發請求的測試都可能遇到,之後若有群組也需要跨重啟測試,記得比照這裡的重試寫法**。
> - **J-3 觸發訊息的記憶回顧**是刻意簡化的版本:直接重用 C-6 的 `KeywordMemoryRetriever` 以任務內容當查詢字,取第一筆結果,且只有在該記憶內容「包含」任務內容字串時才附加回顧文字,避免牽強附會的引用。沒有做更複雜的語意關聯判斷(留給 R-2 的 pgvector 語意檢索之後自然變好)。
> - **J-4 逾期追擊**與**J-6 主動關照**都刻意重用既有的稀有度節流寫法:J-4 用 `Task.overdueNudgeCount`/`lastNudgeAt` 兩個欄位控制節奏(首次追擊需等 `OVERDUE_GRACE_MINUTES`,之後每次追擊需等 `OVERDUE_NUDGE_INTERVAL_MINUTES`),追擊次數累積到 `OVERDUE_IGNORED_NUDGE_COUNT`(預設 3 次)仍無回應才會判定「被忽視」;J-6 則是獨立的 `ProactiveCareLog` 表+`PROACTIVE_CARE_COOLDOWN_HOURS` 冷卻窗(風格對齊 G-4 反差萌與 I-4 破例規則的冷卻表寫法),且會排除「已經講過的同一句台詞」,避免同一句反覆出現。
> - **J-5 委託/關係/情緒的迴路**:任務**建立**(接案)與**完成**都各寫一筆正向 `SentimentLedgerEntry`(事件名稱 `TASK_ACCEPTED`/`TASK_COMPLETED`);「被忽視」則寫負向事件 `TASK_IGNORED`,且直接複用 I-5 引進的 `EmotionService.baselineSignal` 機制(傳入空文字+`{tag:"SAD", intensity:0.3}` 的基線訊號)讓情緒真的往低落偏一點,不必另外幫 `EmotionService` 開新的公開方法。「被感謝提升情緒」則完全不需要額外程式碼——D-1 的 `RuleBasedEmotionTagger` 本來就把「謝謝」標記為 JOY 關鍵字,只要使用者在對話中道謝,既有的 `DialogueService.handleMessage` 情緒管線就會自然生效,J 群組不重複實作。「重複同類委託形成默契旗標」用最簡單的方式判斷:查詢同角色/同使用者/同類型/同內容的委託累積次數(含本次)是否達到 `RAPPORT_THRESHOLD`(預設 2 次),回傳布林旗標 `rapport`,暫不影響任何其他行為(留給後續群組視需要使用這個旗標調整演出)。
> - `TaskModule` 依賴 `ScheduleModule`(J-3 語氣判斷)、`EmotionModule`(J-5 情緒訊號)、`RelationshipModule`(J-5 關係帳本)、`MemoryModule`(J-3 記憶檢索、J-7 的 `JOB_QUEUE`),已在 `app.module.ts` 註冊。**目前 `DialogueService.handleMessage` 尚未整合委託子系統**——也就是說一般聊天訊息不會自動被判斷成委託請求並建立任務,`/task/:characterId/parse` 目前是獨立端點。若之後要讓使用者在一般對話中直接說「三點提醒我開會」就自動建立任務,需要在 `DialogueService` 裡加入呼叫 `TaskService.tryCreateFromText` 的分支(類似目前 fastChannel/schedule exception 的插入方式),並決定解析成功時要不要跳過一般的 LLM 生成、改用接案演出句作為回覆。
### K. 對話模式:群聊與角色自聊
- [x] **K-1 在場者模型(S)**:session 支援多角色參與者名冊,角色可讀取「誰在場」。驗收:群聊 session 內每個角色都能取得完整在場名單。依據:§群聊中的行為變化「在場者感知」。
- [x] **K-2 群聊行為變化(S)**:人前矜持(一對一會撒嬌的角色群聊時收斂、傲嬌更嘴硬)、對不同對象使用不同稱呼與語氣。驗收:同角色在一對一與群聊的同一情境輸出不同。依據:§群聊中的行為變化。
- [x] **K-3 隱私邊界(S)**:一對一聊過的私密內容,該角色在群聊中不主動洩漏(口風不緊為角色設定的例外)。驗收:標記為私密的記憶不會出現在群聊回應中。依據:§跨模式的記憶連續性「隱私邊界」。
- [x] **K-4 發言權分配(M)**:每輪計算各角色發言衝動值(被點名/話題相關度/性格基線/情緒狀態/與發言者關係/發言冷卻),超過門檻才發言;沉默也是演出。驗收:三無角色整場只發言少數次、元氣角色發言最多、剛發言者衝動下降。依據:§發言權分配表。
- [x] **K-5 群聊記憶投影(M)**:群聊記錄為一份共用場景記錄,session 結束時各角色以自身視角萃取記憶,情緒標記可不同。驗收:同一場群聊固化後,兩角色的情節記憶內容與權重不同。依據:§群聊的記憶投影。
- [x] **K-6 角色自聊(M)**:話題種子(使用者指定/共同記憶抽取/日常情境模板)、發言權沿用群聊機制、空轉偵測(重複與資訊量下降)注入轉折或收尾、輪數上限硬停損、使用者插話即切換群聊。驗收:無人插話時能自然收尾且不超過輪數上限。依據:§模式三:角色自聊(旁觀模式)。
- [x] **K-V 階段驗證(XS)**:`npm run restart && npm run smoke -- K`(K.mjs:在場名單、發言權分配、群聊矜持、隱私邊界、記憶投影、角色自聊收斂)。
> **實作記錄(K 群組)**:
> - 群聊/自聊子系統放在 `apps/api/src/room/`。**刻意不建 `Room`/`RoomTurn` 之類的資料表**:`RoomService` 用純記憶體 `Map`(同 C-1 `WorkingMemoryService` 的設計哲學)存參與者名冊與場景逐輪記錄,因為這本質上是 session 期間的暫存場景,session 結束後就該由 K-5 的固化流程萃取成各角色的長期記憶並丟棄原始記錄——長期保存的只有固化後的 `EpisodicMemory`。**這意味著 api 行程重啟會讓所有進行中的房間消失**,跟 C-1 的工作記憶是同一個已知取捨,不是 bug。
> - 新增了 `CharacterRelationship` 資料表(有向邊:`characterId` 對 `otherCharacterId` 的觀感,`affinity` 0~100 + `dynamic` 描述性標籤),供「角色間關係上場」與 K-4 發言權分配裡「與發言者的關係」使用;未設定時預設中性值 50。這是全新的角色對角色關係系統,跟 E 群組的 `Relationship`(角色對使用者)是兩張獨立的表,不要混用。
> - `EpisodicMemory` 新增 `isPrivate` 欄位(K-3),`packages/shared` 的 `EpisodicMemory` 型別也要同步加這個欄位(否則 apps/api 引用共享型別時型別會不匹配)。`KeywordMemoryRetriever.retrieve` 新增 `excludePrivate` 選項;**誰來決定要不要排除私密記憶是呼叫端的責任**——`RoomGenerationService` 一律傳 `excludePrivate: !archetypeParams.traits.includes("loose-lipped")`,一對一對話(`ContextAssemblerService`)完全沒動、不會受影響。`天然呆` 原型加了 `loose-lipped` 特徵旗標作為「口風不緊」的例外原型。
> - **K-4 發言權分配公式**:`score = 性格基線×0.3 + 話題相關度×0.25 + 情緒修正×0.15 + 關係修正×0.15 + 冷落累積加成 − 剛發言冷卻懲罰`,被點名時再加一個很大的固定加成(0.6,幾乎必回),門檻設在 0.25。**話題相關度刻意不是單純的「命中詞數/清單總詞數」比例**:角色喜好清單通常只有幾個詞,一旦命中就該是強訊號,用比例會被清單長度稀釋掉,所以命中時下限給 0.5(`Math.max(0.5, hits/keywords.length)`)。中文以單字成詞很常見(貓/狗/書),關鍵字清單的切詞正規表達式必須把全角「:」「;」都當分隔符,且長度門檻不能設 `>=2`(否則會濾掉單字關鍵字)——**這是本群組踩到的實際 bug,切詞沒處理全角標點導致話題相關度永遠算不出命中**,已修正並在 `speaking-right.service.ts` 留了註解說明。
> - **K-2 群聊矜持的實作方式很輕巧**:完全重用 G-3 的親密度分層模板系統(`intimacyTier`/`pickTemplates`),群聊/自聊時把「餵給生成上下文的親密度」下修 35(傲嬌再多扣 15),讓同一套模板庫自然选到「低親密度」那一層、更收斂的回應,**沒有另外做一套群聊專用模板**。副作用:`MockProvider` 目前完全不吃 `context.history`/`context.retrievedMemories`,所以「對不同對象不同稱呼」是在 `RoomGenerationService` 外面手動 prepend 稱呼字串(`"${address},${生成文字}"`),不是模板系統原生支援對象切換——這也是為什麼 K-3 的隱私過濾沒辦法用「回應文字裡有沒有出現私密內容」來驗收(MockProvider 根本不會把記憶內容寫進輸出),K.mjs 改成直接打 `GET /memory/:characterId/retrieve?excludePrivate=` 驗證過濾機制本身。**日後 R-3 換上真正的 LLM Provider 後,這裡的稱呼/矜持處理方式可能需要重新設計**(真正的 LLM 應該能在 prompt 裡吃到「這是群聊,在場者有誰」而自然產生矜持與對象切換,不必再靠外部下修親密度這個折衷做法)。
> - **K-5 記憶投影的「被虧的記得比較牢」**:固化時逐輪呼叫 D-1 的 `RuleBasedEmotionTagger` 幫每一句話打情緒標記,非自己說的話只有「情緒強烈」或「提到自己(別名比對)」才留存,權重公式 `aboutMe ? 1.5+intensity : isSelf ? 1+intensity : 0.5+intensity`——同一句被虧的台詞,當事人權重 2.2、單純旁觀的角色權重只有 1.2(已用 smoke test 驗證這個差距一定成立)。
> - **K-6 角色自聊的收斂機制**:完全重用 K-4 的 `SpeakingRightService.decideSpeakers`(把上一輪發言內容當作「發言者」丟進去算下一輪各角色的衝動值,衝動最高者發言;若全部低於門檻仍強制選最高分者發言,否則自聊會卡死不動),空轉偵測比對最近 3 轮内容是否完全重複或字數持續遞減,偵測到就注入 `TOPIC_TWIST_LINES` 轉折句;同一房間累積 2 次轉折仍空轉就直接收尾(`SELF_CHAT_WRAP_UP_LINES`)。**因為 MockProvider 的模板池很小(多數原型只有通用預設兩句),空轉偵測在測試裡幾乎每次都會被觸發**,這是預期行為,不是 bug——等 R-3 真正的 LLM 上線後,重複發生的機率會大幅降低,但空轉偵測機制本身仍應保留(真人對話一樣會陷入互相客套的迴圈)。使用者插話(`interject`)只是把 `room.mode` 從 `SELF_CHAT` 切成 `GROUP`,插話內容走跟一般群聊訊息完全相同的路徑。
> - `RoomModule` 依賴 `LlmModule` 取得 `LLM_PROVIDER`(重用 F-2 的 Provider 抽象),但 `LlmModule` 原本只 export `DialogueService`/`BehaviorReinforcementService`,**這次補上 `LLM_PROVIDER` 到 export 清單**,否則 `RoomGenerationService` 在 DI 階段會解析不到這個 token 而直接炸掉啟動。之後任何模組想直接注入 `LLM_PROVIDER`(不透過 `DialogueService`)都要記得先確認它在 exports 裡。
> - **`DialogueService`(一對一聊天)完全沒有被本群組改動**——群聊/自聊是平行的一套生成路徑(`RoomGenerationService`),不是在 `DialogueService` 裡加分支。這是刻意的取捨:一對一的管線已經很長(作息/破例/記憶檢索/禁則),硬塞群聊語意進去會讓兩種模式互相拖累;缺點是兩套生成路徑目前有一些邏輯重複(組裝 `GenerationContext` 的細節),**如果之後要讓兩套路徑共用更多邏輯,可以考慮把 `ContextAssemblerService` 抽出一個「不含 session/schedule 依賴」的核心組裝函式,供两邊共用**,本群組沒有做這個重構。
### L. 戀愛關係軸與內容尺度
- [x] **L-1 雙軸模型(S)**:新增心動值欄位(0~100),與親密度獨立:親密度日常穩定累積且衰減慢,心動值事件驅動、衰減快、易回落。驗收:純聊天量只推升親密度、不推升心動值。依據:§雙軸模型「純聊天量刷不出戀愛」。
- [x] **L-2 告白事件(S)**:答覆由當時心動值與性格決定,可以被拒絕;拒絕不清空累積但進入尷尬期,需修復事件回溫,連續失敗真的傷關係;拒絕後立即再推進觸發警戒與心動值下降。驗收:心動值不足時告白被拒且進入尷尬期狀態。依據:§拒絕與失敗的處理。
- [x] **L-3 性格進展曲線(S)**:六原型各自的戀愛軸走勢(傲嬌敵意與心動同步上升、冷淡極緩線性、元氣卡曖昧期、大小姐階梯跳升、三無幾無外顯)。驗收:相同事件序列在不同原型下產生不同曲線。依據:§性格決定進展曲線。
- [x] **L-4 親密表現分層(S)**:在意~曖昧、戀人初期、戀人穩定期、深度伴侶各階段開放的親密表現不同;每個「第一次」做成事件級演出(高權重記憶);跳級要求得到該階段該有的退縮反應並記入帳本。驗收:階段未到時的跳級要求被拒且留下帳本紀錄。依據:§親密表現與關係階段的對應。
- [x] **L-5 心動事件與階段門檻(M)**:實作五類心動事件(共同經歷高情緒事件、被理解的瞬間、展現她欣賞的特質、恰到好處的距離感、失望事件回落)與 朋友→在意→曖昧→戀人→深度伴侶 的階段推進、冷卻回落、關係危機。驗收:推得太急會回落、停滯會冷卻。依據:§戀愛階段與關鍵事件門檻、§心動事件表。
- [x] **L-6 角色年齡判定(M)**:年齡為年表上的屬性,依作品進度錨定點判定;證據優先序(官方明示 > 作中身分 > 作中經過時間 > 外觀輔助),不採信「外表少女實為千年」類設定,無法確定一律從嚴按未成年處理;錨定點必須是作品實際描寫過的時期。驗收:證據不足的角色被判為未成年。依據:§作品中走向成年的角色:年齡判定。
- [x] **L-7 內容尺度三層邊界(M)**:於**引擎層**(非演出層)實作使用者分級 opt-in(預設全關)、角色資格(僅正史成年)、硬邊界(未成年角色相關、非合意、暴力性內容,永不可解除);正史未成年→後日談自然成年者:戀愛軸可用但成人模式永久排除、成年前戀愛軸完全關閉且成年後從零開始、時間流速上限 1:1、戀愛軸無手動開關。驗收:任何繞過嘗試(含「說服角色」)皆被引擎層攔截並記錄。依據:§內容尺度、§後日談成年與戀愛軸資格:兩級制。
- [x] **L-V 階段驗證(S)**:`npm run restart && npm run smoke -- L`(L.mjs:未成年角色戀愛軸不啟動、硬邊界攔截、告白可失敗、心動值不因閒聊上升)。
> **實作記錄(L 群組)**:
> - **安全範疇的刻意收斂**:本群組實際落地的是「戀愛軸機制」與「內容尺度的引擎層資格判定/硬邊界攔截」——即 L-1~L-7 描述的計量、狀態機、年齡判定、opt-in/資格/硬邊界三層。**刻意沒有實作、也不會實作任何成人向內容的實際生成**(沒有 explicit 內容模板、沒有「生成成人內容」的方法);`RomanceEligibilityService.checkAdultContentAllowed` 目前是唯一的判定入口,回傳 `allowed:true` 時也只代表「資格檢查通過」,**呼叫端目前沒有任何後續會真的產生成人向文字**——這條路徑存在的意義是把「誰能不能」這個判斷做對、做嚴、做成不能被繞過的引擎層邏輯,內容生成本身超出目前所有已完成群組(含本群組)的實作範圍,留白處理。全年齡尺度內的親密演出(牽手/擁抱/額頭吻)則正常實作在 `MilestoneService`。
> - 新模組放在 `apps/api/src/romance/`。`Relationship` 資料表直接加上戀愛軸欄位(`romanceMeter`/`romanceStage`/`lastRomanceEventAt`/`awkwardUntil`/`consecutiveRejections`/`partnerSince`/`romanceEventsInStage`),沒有另開一張表——因為戀愛軸終究是「這個角色對這個使用者」的關係狀態,跟友誼軸(`intimacy`)綁在同一個 unique key 上最自然,兩組欄位互不干涉即可達成 L-1 的雙軸獨立。
> - **L-6/L-7 是本群組唯一該仔細看的部分**:`AgeDeterminationService.determine()` 永遠是「重新算出來的」,資料庫只存原始證據(`CharacterAgeEvidence`),不存最終結論——避免證據更新後結論欄位忘記同步的錯誤。判定邏輯只認 `OFFICIAL_STATED`/`IN_STORY_STATUS`/`ELAPSED_TIME` 三種證據類型(決定性或強),`APPEARANCE`(外觀與社會呈現)**不論 `impliesAdult` 標記為何一律不採信**,程式碼裡就是直接排除在判定用的證據集合外,不是「權重比較低」而是「完全不算」,對應文件裡「不採信『外表少女實為千年』類設定」的硬規則。沒有任何證據時預設 `MINOR`(`Array.some` 對空陣列回傳 `false`),符合「無法確定一律從嚴」。
> - **兩級制的資料模型**:`CharacterAgeEvidence.isEpilogue` 標記這筆證據是否屬於後日談推演區間。`AgeDeterminationService` 同時算兩個值:`canonAdult`(只看 `isEpilogue=false` 的證據)與 `currentAdult`(看全部證據)。`RomanceEligibilityService` 把這兩個值轉成 `romanceAxisEligible=currentAdult`(戀愛軸能不能動)與 `adultModeEligible=canonAdult`(成人模式資格,**永久**——只要正史證據沒有變成成年,這個值永遠是 false,不管後日談推演了多少年、不管使用者有沒有 opt-in)。`checkAdultContentAllowed` 是唯一入口,且**不論放行與否都寫一筆 `AdultContentAttemptLog`**,滿足「任何繞過嘗試皆被攔截並記錄」——這裡刻意不去檢查「使用者是不是在用話術說服角色」,因為判定完全不看對話內容,只看角色資格與使用者驗證狀態,這正是文件裡「硬邊界與尺度上限在引擎層執行,不存在『說服角色』繞過的可能」的字面實作:說服角色沒有任何輸入管道能影響這個判定函式。
> - **L-7 的「成年前戀愛軸完全關閉」**:`RomanceService.recordEvent` 一開始就檢查 `romanceAxisEligible`,不具資格時直接回傳 `applied:false` 並跳過所有計算——不寫入、不累積,是機制層面「這功能對這個角色不存在」而不是「算出來但被擋下」。这与 `checkAdultContentAllowed`(會記錄攔截)是刻意不同的两种語意:戀愛軸未啟動是正常狀態轉換的一部分,成人內容檢查才是需要留痕的安全關卡。
> - **L-3 性格曲線**:`archetype-params.ts` 新增 `romanceGainRate`/`romanceBigEventBonus`/`romanceAmbiguousStallFactor` 三個係數。傲嬌的「心動值與外顯敵意同步上升」**沒有新寫邏輯**,直接重用 G-3 `computeExpressedIntimacy` 的 tsundere gap 機制(把心動值當作 internalIntimacy 傳進去即可,外顯部分自然反向);三無的「幾無外顯訊號」同樣不影響心動值曲線本身(`romanceGainRate` 維持 1),因為那是表演層(`expressiveness`)的職責,不是計量層——**這條界線要守住:曲線速度與外顯程度是兩個獨立的參數,不要混在一起改**。大小姐的「階梯狀跳升」用 `romanceBigEventBonus=2.2` 讓她在「共同經歷高情緒事件/被理解的瞬間」這兩種大事件上跳很多、其他小事件幾乎不動,模擬階梯感(不是真的用不同的曲線函式形狀,是用大小事件的倍率差異模擬出類似效果,足夠通過驗收但不是嚴格的「函式形狀」意義上的階梯)。
> - **衰減與階段機制有交互作用,測試時要小心**:`romanceMeter` 用 5 天半衰期惰性衰減(讀取時才補算,同 E-4 手法),階段門檻(在意 20/曖昧 50)有 5 點的降級緩衝帶避免抖動。**這造成一個容易踩到的陷阱**:任何讀取戀愛軸狀態的呼叫(`GET /romance/:characterId/:userId/state`)如果不傳 `now`,會用「真實當下時間」計算衰減——如果测试用的是模拟过去的日期(例如 2026-03-04)灌事件,事後用不帶 `now` 的查詢去看結果,衰減會用「真實現在」(例如 2026-08-13)反推,五個月遠超過 5 天半衰期,數值會被衰減到幾乎歸零、階段被打回 NONE。**本群組寫測試時真的踩到這個坑**:`L.mjs` 一開始沒給 `state` 端點加 `now` 查詢參數,測到「累積心動事件後應進入曖昧階段」那一步炸掉,結論是「任何用模擬時間軸驅動事件的測試,讀狀態也必須帶上同一條時間軸上的 `now`,不能倚賴端點預設的『現在』」——已經幫 `GET .../state` 加上 `now` 查詢參數,未來若有其他群組要讀這個端點做時間敏感的驗證,記得帶。
> - `AdultModeConsent` 綁在使用者身上(跨角色共用同一份 opt-in 設定),不是綁在單次請求或單個角色關係上——這對齊文件「使用者分級」是三層邊界裡最外層、最粗粒度的一層。**由於測試共用同一個種子使用者 `seed-user-primary`**,`L.mjs` 在開頭與結尾都會重置這個使用者的 `AdultModeConsent`,避免這個跨群組共用的全域設定污染其他群組或自己重跑時的判定結果——這是本群組另一個真的踩到的冪等性陷阱,已在程式碼註解說明。
### M. 既有作品考據與輕小說章節管線
- [x] **M-1 插圖索引(S)**:登錄插圖(卷數/頁碼/對應場景/類型=封面/彩頁/黑白),並衍生服裝目錄與姿勢語彙表;立繪服裝層只能從目錄取用、不自創。驗收:可由事件查到對應插圖,服裝目錄可列出。依據:§插圖作為立繪參考、§插圖與文本的交叉索引。
- [x] **M-2 作品名冊與建置門檻(S)**:角色名冊登錄正式名/暱稱/他人稱呼,新角色先進候補狀態,達門檻(出場場景數、具名台詞數、與主要角色實質互動)才正式建置。驗收:路人角色維持候補、主要角色達標後自動建置。依據:§章節匯入流程:強化或新建、§建置門檻。
- [x] **M-3 章節匯入與場景切分(M)**:匯入章節文本,以場景為單位切分並寫入**共用場景資料庫**(客觀記錄:在場者、誰說了哪句、誰做了什麼,不含主觀詮釋)。驗收:以自製測試文本匯入後可列出場景與在場者。依據:§共用場景資料庫:處理一次,各自投影。註記:測試文本不得使用受版權保護的原文(需人工確認來源)。
- [x] **M-4 逐場景萃取(M)**:萃取事件、目標角色台詞、內心獨白/心理描寫、與他人的互動、新登場設定五類產物。驗收:五類產物各自入庫且可追溯到來源場景。依據:§逐章處理管線。
- [x] **M-5 歸屬信心分數(M)**:每句台詞/動作的歸屬附信心分數,證據強度依序為 明示標記(決定性)>語言指紋(強)>場景名冊(硬約束:不在場者不可能說話)>對話輪替(中)>內容合理性(中)。驗收:無標記對話能給出帶信心分數的歸屬。依據:§防線一:歸屬時的多重證據與信心分數。
- [x] **M-6 隔離區與指紋冷啟動(M)**:信心低於門檻或與既有語言指紋矛盾者一律進隔離區、不寫入任何角色,累積成待裁決清單;前幾章先用有明示標記的台詞建立指紋基準再回頭處理無標記對話。驗收:低信心台詞不污染角色語料,裁決後可釋放入庫。依據:§防線二:寫入前的交叉驗證。
- [x] **M-7 多角色投影(M)**:由場景資料庫投影出各角色的情節記憶(以她的視角改寫、她不在場的不記得、她誤解的按她以為的版本存)、語料與關係帳本事件;修正只改場景資料庫一處後重新投影。驗收:修正一句台詞歸屬後,受影響的多個角色資料同步更新。依據:§共用場景資料庫、§各類萃取物的處理規則。
- [x] **M-8 跨章節整合(M)**:共用年表排序、依文本篇幅與心理描寫深度評情緒權重、矛盾偵測與裁決優先序(小說原文 > 官方設定集 > 動畫改編),無法裁決標「待人工確認」。驗收:故意置入的矛盾被偵測並標記。依據:§跨章節整合的關鍵。
- [x] **M-9 錨定點與知識邊界(M)**:作品進度錨定點統一於作品層級,錨定點之前為角色的親身經歷、之後不存在;性格參數為帶時間戳的序列,依錨定點取值;同作品角色共用錨定點但年齡逐角色判定。驗收:錨定點前移後,角色不再知道後段劇情。依據:§跨章節整合「時間點錨定」、§錨定時間點的統一。
- [x] **M-10 增量補全與回溯修正(M)**:新卷匯入時追加至年表尾端(錨定點之後者存為未啟用);新資訊可回溯修正舊參數;已上線角色發現歷史歸屬錯誤時,修正場景資料庫 → 重新投影 → 重算指紋與關係帳本,但**與使用者已發生的互動記憶不回溯竄改**,改以「想起來其實不是這樣」的新互動事件消化。驗收:修正後使用者互動記錄完整保留。依據:§增量補全、§誤判發現得晚怎麼辦。
- [x] **M-V 階段驗證(S)**:`npm run restart && npm run smoke -- M`(M.mjs:匯入測試文本 → 場景記錄 → 兩角色投影 → 客觀事實一致性檢核通過、主觀詮釋差異被允許)。
> **實作記錄(M 群組)**:
> - 新模組放在 `apps/api/src/canon/`。**M-3 場景切分的刻意簡化**:這裡不做自由文本的自動場景邊界偵測(真正的章節文本→場景切分需要相當於一個 NLP 分段模型),改成接受「呼叫端已經切好場景、逐行標好類型」的結構化輸入(`ImportSceneInput`:`lines: [{lineType, rawText, hasExplicitMarker?, markerCharacterId?}]`)——這個簡化與 F-7 `MockProvider` 代替真實 LLM 是同一種取捨:引擎的其他機制(信心分數、隔離區、投影、錨定點)都能在沒有真正 NLP 分段器的情況下完整測試,之後要接上真的自動分段/自動偵測明示標記,只需要在 `SceneImportService` 前面補一層前處理,不影響下游任何機制。**M.mjs 的測試文本全部是本次會話自製的短句,不是任何受版權保護的原作內容**,符合 M-3 驗收註記的要求。
> - **場景資料庫(`Scene`/`ScenePresence`/`SceneLine`/`SceneFact`/`SceneInteraction`)是角色中立的客觀記錄,投影(寫入 `EpisodicMemory`/`SemanticMemory`/`PersonalityTraitSnapshot`/`CharacterRelationshipEventLog`)是完全獨立的第二步**,兩者由 `ProjectionService` 銜接。這個分層是 M-7「修正只改一處、重新投影全部同步」與 M-9「知識邊界」共同的地基:`Scene.projected` 這個布林欄位記錄「這場戲的效果有沒有套用到角色資料」,`ProjectionService.reprojectScene` 永遠是「先刪掉這個場景先前投影出的所有資料(用 `sourceSceneId`/`sceneId` 精準篩選),再重新投影一次」,不是就地修改——這保證了「改一處、處處同步」不會有殘留的舊資料。
> - **M-9 錨定點知識邊界是這個分層的直接推論,幾乎不需要額外程式碼**:`SceneImportService` 只在 `scene.storyOrder <= work.anchorStoryOrder` 時才呼叫 `projectScene`;超前的場景就乖乖留在場景資料庫裡等錨定點推進。`AnchorService.setAnchor` 同時處理**前進**(把新進入範圍的場景投影進去)與**後退**(把新排除在範圍外、但先前已投影的場景資料清掉,`Scene.projected` 打回 `false`)——**這裡刻意支援雙向**,因為「使用者想避免爆雷、把錨定點設回比之前更早的章節」是真實會發生的操作,不是只有「追完新一季往前推」這種單向情境。
> - **M-10「與使用者互動記憶不回溯竄改」是結構性保證,不是靠邏輯判斷做到的**:`sourceSceneId`/`sceneId` 這類可追溯欄位只有場景投影會寫入,`source: "INTERACTION"` 的記憶(一對一聊天產生的)從來不會有這些欄位——因此 `reprojectScene`/`AnchorService` 的刪除查詢天然就篩不到使用者互動記憶,不需要額外寫「排除 INTERACTION」的特殊判斷,少一行防禦性程式碼就少一個之後可能漏寫的風險點。已上線角色發現歷史歸屬錯誤時(`CorrectionService.correctLineAttribution`),除了觸發重新投影,還會分別幫舊歸屬角色與新歸屬角色各記一筆 `source: "CORRECTION"` 的新事件(「想起來其實不是這樣」),**新增了 `MemorySource.CORRECTION` 這個列舉值**(`packages/shared` 與 Prisma schema 都要同步加,兩邊型別要對得上,這次建置時就因為漏了 `packages/shared` 那邊而卡到一次型別錯誤)。
> - **M-5/M-6 語言指紋是「從語料統計學出來的」,不是 G 群組那種原型模板**:`FingerprintService` 從 `LanguageCorpusEntry`(已確認歸屬的台詞)統計一組特徵詞(`SIGNAL_TOKENS`)的出現頻率,跟 G-2 `language-style.ts` 裡手寫的「這個原型絕不用驚嘆號」是兩套完全不同的機制、不要混淆:G 群組的是**設計時鎖定**的角色語言風格規則,M 群組的指紋是**從已歸屬文本統計出來**的、會隨語料增加而變準的證據來源。指紋樣本量門檻(`FINGERPRINT_MIN_SAMPLES=3`)很重要:樣本不足時一律回傳中性分數 0.5,不會因為剛好命中一個特徵詞就誤判——冷啟動階段(只有明示標記台詞、語料還很少)本來就該保守,這正是 M-6「先用明示標記建立指紋基準,再回頭處理無標記對話」的字面意思。
> - **中文分詞的經驗延續**:K 群組在 `speaking-right.service.ts` 踩過「切詞正規表達式沒把全角標點當分隔符,導致單字特徵詞比對不到」的坑(見 K 群組實作記錄);這次 `fingerprint.service.ts`/`attribution.service.ts` 一開始就用單字級的 `text.includes(token)` 子字串比對(完全不切詞),直接繞開同一類分詞陷阱,是刻意選的簡化方案。
> - **建置門檻檢查(M-2)的呼叫順序是個真的踩到的 bug**:一開始 `checkAndPromote` 排在 `projectScene` 之前呼叫,導致「與其他角色的實質互動」這個門檻條件永遠用「上一次投影完」的舊資料去判斷(這次匯入場景帶來的新互動還沒套用到 `CharacterRelationshipEventLog`,因為那是投影階段才寫入的)——結果角色明明已經達標卻沒被自動建置。修正成**投影完才檢查門檻**,`AnchorService.setAnchor` 推進錨定點時也一樣要在對應場景投影完之後才檢查——**這類「事件觸發的統計門檻檢查」永遠要排在所有會影響統計結果的寫入完成之後**,這條經驗值得日後任何群組寫類似的自動判定邏輯時留意。
> - **角色間互動的關係帳本刻意寫雙方視角、且權重可以不對稱**:`SceneImportService` 接受的 `interactions` 輸入是呼叫端(人工/未來的萃取流程)明確給的「誰對誰做了什麼、正負權重多少」,並沒有做真正的情感分析去推論互動的正負向——這是本群組另一個「先用結構化輸入代替真實 NLP」的簡化,跟場景切分是同一種取捨,理由也相同:機制先做對,之後有真的 NLP 能力再接上去替換輸入來源即可,不影響 `CharacterRelationshipEventLog`/建置門檻/關係一起成長這些下游機制。
> - **M-8 矛盾偵測只處理「同一事件標籤下、同一事實鍵卻有不同事實值」這種最容易驗證的矛盾形式**(`SceneFact`:`eventLabel`+`factKey`+`factValue`),裁決優先序(小說原文 > 官方設定集 > 動畫改編)靠 `Scene.sourceType` 判斷;同優先序來源互相矛盾(例如兩段小說原文互相衝突)沒有客觀依據可以自動選邊,標記 `PENDING_HUMAN_REVIEW`,不會硬選一個。**沒有做語意層級的矛盾偵測**(例如兩段描述用不同措辭講同一件事實但沒有明確標成同一個 `eventLabel`)——這需要真正的自然語言理解,超出本群組範疇,留給日後有實際 LLM 介入文本處理階段時再擴充。
### N. 後日談模式
- [x] **N-1 區間切換與時間流速(S)**:錨定點抵達已出版內容盡頭時切入後日談區間;流速可設即時同步/緩速/凍結,且對「正史未成年→後日談成年」的角色上限為 1:1(只能調慢或凍結)。驗收:該類角色無法設定快轉流速。依據:§時間流速設定、§兩級制「時間不可快轉」。
- [x] **N-2 性格漂移(S)**:性格核心鎖定不變,僅參數緩慢漂移(情緒衰減加快、外顯度微調),漂移方向由正史成長軌跡外插,不憑空轉向。驗收:長期推演後原型判定不變、參數有小幅位移。依據:§後日談的成長內容「性格的成熟是漂移,不是改寫」。
- [x] **N-3 角色群同步成長(S)**:同作品角色共用世界時鐘與場景資料庫,推演人生互相一致(A 的婚禮 B 有出席)。驗收:兩角色對同一推演事件的敘述互相呼應。依據:§後日談的成長內容「角色群同步成長」。
- [x] **N-4 人生階段推演(M)**:依正史確立的目標與性格推演里程碑事件(籌備、失敗、再試),列為高權重事件且使用者可參與;重大轉折需低頻且有正史伏筆。驗收:推演事件不違反角色一致性與世界觀。依據:§後日談的成長內容、§成長的邊界。
- [x] **N-5 正史回收與分支保留(M)**:官方續篇出版時提供兩種處理——正史回收(推演區間被替換,互動記憶保留,並以「記憶修正」演出敘事化消化)或分支保留(永久分岔);選擇權在使用者,並依推演深淺給出預設建議。驗收:兩種路徑皆可執行且互動記憶不遺失。依據:§官方續篇出版時:正史回收。
- [x] **N-V 階段驗證(XS)**:`npm run restart && npm run smoke -- N`(N.mjs:流速上限規則、性格核心鎖定、正史回收後互動記憶保留)。
> **實作記錄(N 群組)**:
> - 新模組放在 `apps/api/src/epilogue/`,重度依賴前面兩個群組已經建好的地基:**L 群組**的 `RomanceEligibilityService.getEligibility().isEpilogueAdult` 直接拿來做 N-1 的流速限制判斷(同一個「正史未成年、後日談推演成年」旗標,L 群組用在成人模式資格,這裡用在時間流速資格——兩處判斷邏輯完全共用,沒有重複實作);**M 群組**的 `Scene`/`ScenePresence`/`SceneLine`/`ProjectionService` 直接拿來實作 N-3/N-4 的「人生階段推演事件」——系統生成的推演事件說到底就是一場「場景」,只是 `sourceType="EPILOGUE"`(一個一般字串欄位,不是嚴格的資料庫層級 enum,用一個新字串值就能表示,不需要改 schema),在場者是誰就會投影出對應記憶給誰,這正是「A 的婚禮 B 有出席」的實作基礎——**沒有另外寫一套「多角色事件廣播」機制**。
> - **N-1 時間流速的「上限為 1:1」其實是本系統原本就沒有比 1:1 更快的模式**:文件裡的時間流速表只列了即時同步/緩速/凍結三種,全部都不超過現實 1:1。為了讓 N-1 的驗收「該類角色無法設定快轉流速」有實際意義,這裡刻意新增了一個 `FAST`(加速)選項供**一般角色**使用(不在原文件的表格裡,是本群組為了讓限制可驗證而添加的介面)——`TimeFlowService.setTimeFlow` 只在模式為 `FAST` 且作品內**任何一個角色**是後日談推演成年時才拒絕,因為流速是作品層級的共用世界時鐘設定(同作品角色共用時鐘),只要有一個角色受限,整個作品的時鐘就不能調快。**如果之後真的要拿掉 `FAST` 這個非文件既定選項,只要把 `TIME_FLOW_MODES` 跟這條 if 檢查一起刪掉即可**,不影響其他機制。
> - **N-2 性格漂移刻意做成「沒有證據就不轉向」**:`PersonalityDriftService` 只有在角色的正史 `PersonalityTraitSnapshot`(M-8 產物,帶 `expressivenessOverride` 的那些)至少有兩筆時才會算出一個非零趨勢(用首尾兩筆的差除以年表距離當斜率),否則漂移量鎖定在 0——**這正是「不憑空轉向」的字面實作**:沒有正史證據支撐方向,就完全不漂移,不是漂移一個隨機或預設方向。漂移幅度與情緒衰減加快幅度都設了上限(`MAX_EXPRESSIVENESS_DRIFT=0.15`/`MAX_HALF_LIFE_REDUCTION=0.3`),對應「性格核心鎖定、只是參數緩慢位移」——`archetype` 這個字串本身在這條路徑上完全沒被寫入或改動過,「原型判定不變」是結構性保證,不是驗收時才去確認的副作用。
> - **N-4 人生階段推演的模板比對非常樸素**:`LifeEventService` 用一個「關鍵字對應到里程碑模板」的小清單(例如 `goalsObsessions` 含「麵包店」才會生成籌備/開幕系列事件),刻意設計成「對不上任何關鍵字就不生成」——這是「重大轉折需要正史伏筆」的直接實作方式:沒有伏筆(目標文字裡沒有對應關鍵字)就不編。低頻節流靠 `EpilogueMilestoneLog` 記錄上次里程碑的年表位置,兩次至少要間隔 `MILESTONE_MIN_STORY_GAP` 個單位。**這跟 M 群組「先用結構化輸入代替真實 NLP」是同一種取捨**:真正的「依角色性格與目標動態編出人生大事」需要生成式能力,這裡先把機制(低頻節流、事件同步投影、正史伏筆檢查)做對,之後有真的生成能力時只需要替換 `matchTemplate` 這一個函式。
> - **N-5 正史回收踩到一個和 M-9 錨定點語意衝突的真實 bug**:一開始直接呼叫 `SceneImportService.importScene` 匯入官方新場景,結果因為官方場景的 `storyOrder` 通常超過 `work.anchorStoryOrder`(後日談本來就是走在正史錨定點之前的),`importScene` 依 M-9 的知識邊界規則判斷「超前於錨定點,不投影」,導致回收回來的正史內容反而不會被角色「知道」——這暴露了 `anchorStoryOrder` 原本只追蹤「正史知識邊界」,但後日談的「現在」可以走到比它更遠的地方。修正方式:`CanonReclamationService.reclaim` 匯入後**直接呼叫 `ProjectionService.projectScene`**(不透過 `importScene` 的錨定閥門),並把 `anchorStoryOrder` 一併推進到覆蓋這批正史內容,讓「正史知識邊界」跟「已投影的正史範圍」重新同步。**這條經驗提醒之後任何跨群組重用既有服務時,要想清楚對方的前提假設(這裡是『匯入的場景預設尚未被角色經歷過』)是否仍然成立**,不成立時不能直接借用,要繞過去或明確重新同步狀態。
> - **N-5 分支保留擋下重新匯入的錯誤處理**:一開始用裸的 `throw new Error(...)` 擋下已分岔作品的回收嘗試,NestJS 沒認得這個例外型別,回傳了無意義的 500——改成 `ConflictException`(409)。**這是本群組另一個「先用最省事的寫法、跑過一次真實請求才發現不對」的例子**,日後任何服務層拋出的「業務規則擋下」錯誤都該用 NestJS 的 `HttpException` 子類別(`BadRequestException`/`ConflictException`/`ForbiddenException` 等),不要用裸 `Error`——`RomanceEligibilityService`/`TimeFlowService` 已經是對的寫法,這次是漏了一處才補上。
### O. 立繪子系統
- [x] **O-1 差分資產結構(S)**:定義立繪 manifest(身體姿勢 3~5、服裝 2~4、表情眉 4×眼 5×口 5、效果層 臉紅/汗/淚/青筋/音符、微動態 眨眼/呼吸)與資產目錄規範。驗收:以佔位資產可組出一張完整立繪。依據:§立繪的分層合成表格。
- [x] **O-2 表情演出細節(S)**:表情在聽到關鍵字當下就切換(早於文字回覆)、視線方向表達心理狀態、文字與立繪可故意不一致(嘴上說沒事但表情低落)。驗收:回應送出前表情已先變化。依據:§立繪的動態演出。
- [x] **O-3 親密度解鎖(S)**:依 陌生~認識/朋友/摯友/羈絆 分層解鎖表情與服裝差分,哭臉列為最深層。驗收:低親密度時稀有差分不可用。依據:§親密度與外觀解鎖。
- [x] **O-4 情緒→表情對照(M)**:實作平靜/愉悅/低落/警戒/害羞/彆扭 六狀態的眉眼口+效果層+姿勢對照,並依角色外顯度縮放變化幅度(三無角色僅嘴角微動)。驗收:外顯度低的角色差分幅度明顯小於元氣角色。依據:§情緒 → 表情對照、「表情也吃性格參數」。
- [x] **O-5 前端渲染(M)**:以 PixiJS 實作分層合成與微動態(眨眼、呼吸),並預留 Live2D Cubism 介面(授權需人工確認);接上 H-5 的立繪區取代佔位圖形。驗收:對話中表情隨情緒即時變化、常駐微動態運作。依據:技術選型「Live2D / PixiJS:立繪差分渲染」。
- [x] **O-V 階段驗證(S)**:`npm run restart && npm run smoke -- O`(O.mjs:情緒切換後 manifest 選中的差分正確、解鎖規則生效),並實際開啟畫面確認表情變化。
> **實作記錄(O 群組)**:
> - 新增後端模組 `apps/api/src/tachie/`(manifest/表情解析)與前端元件 `apps/web/components/TachieStage.tsx`(PixiJS 渲染)。**決策點的分工原則**:差分「選哪一個」(情緒對照、親密度解鎖、外顯度縮放)是後端邏輯——跟性格參數、關係狀態一樣屬於角色引擎的一部分,前端只負責「把後端算好的選擇畫出來」,不重複判斷規則。這跟其他所有子系統的分工原則一致(引擎算、前端演)。
> - **O-3 親密度解鎖直接重用既有的 `RelationshipStage`**(陌生/認識/朋友/摯友/羈絆),沒有另開新 enum——`STRANGER`/`ACQUAINTANCE` 合併對應文件的「陌生~認識」層級。`ExpressionResolverService` 回傳的 `emotionTag` 是「實際套用的表情」(可能因未解鎖而降級成 CALM),`rawEmotionTag` 才是情緒引擎的真實判定——**這個雙欄位設計同時實現了 O-2 的「文字與立繪可故意不一致」**:生成文字的 `MockProvider` 完全不知道有沒有解鎖這件事,只看真實情緒/親密度/原型,跟表情解析是兩條獨立的資料流,兩者不一致是自然結果,不需要特別寫「故意不一致」的邏輯。哭臉(`CRYING_EFFECT`)額外多加一個「情緒強度 ≥ 0.75 且 stage=BONDED」的雙重條件,只有這裡才會把低落的效果層從「無」升級成「淚」,具體實作了「哭臉是最深層差分」。
> - **O-4 外顯度縮放不是換一套差分素材,而是同一組差分部件套上不同的數值幅度**:`magnitude = 性格外顯度(expressiveness) × 情緒強度`,回傳給前端後由 PixiJS 用這個數值去縮放眉毛抬起高度、眼睛開合、嘴角彎曲弧度——「同樣是愉悅,元氣角色滿臉笑容、三無角色只有嘴角微動」在這裡具體變成同一個 `mouth: "上揚"` 標籤配上完全不同的 `magnitude`(元氣約 0.9、三無約 0.045,測試裡驗證兩者相差超過 3 倍)。這個設計選擇是刻意的:**離散的差分種類(眉/眼/口選哪一個)由情緒決定,連續的幅度由性格決定,兩個維度互不干涉**,之後接上真的美術資產時,`magnitude` 可以直接對應到差分圖層的透明度或位移量。
> - **O-2「表情早於文字」在目前非串流架構下的忠實妥協**:`DialogueService.handleMessage` 本來就是「先更新情緒引擎,再生成文字」(沿續 D/F 群組既有順序,這裡沒有改動),所以聊天 API 回應完成的那一刻,情緒狀態已經是新的了;`GET /tachie/:characterId/:userId/expression` 是一個完全獨立於對話生成的查詢端點,讀到的永遠是「當下」的情緒。前端(`ChatView.tsx`)在收到聊天回應後,**先呼叫這個端點更新表情、等待約 220ms、才把文字訊息加進對話列表**——這是目前系統唯一能做到「玩家先看到表情變化,才看到文字出現」的方式。**如果之後要做到文件描述的「聽到關鍵字的當下」(比使用者送出後、伺服器算完更早),需要真正的串流(SSE/WebSocket)架構,那是超出本群組(甚至超出目前所有已完成群組)範疇的基礎設施,留給 R 群組評估。**
> - **O-5 前端渲染是本群組唯一真正做瀏覽器驗證的部分**:本環境沒有 `chromium-cli` 這個工具(`run` skill 文件提到的指令在這裡不存在),改用在 scratchpad 暫裝 `playwright-core` + `npx playwright-core install chromium` 的方式驗證(沒有把這個相依性留在專案 `package.json` 裡)——這次幸運地沒有像 H 群組那樣遇到缺 `libasound.so.2` 的問題(`ldd` 檢查沒有回報缺任何動態函式庫),可能是環境已經有安裝或這次剛好夠用。**實測過程中第一版驗證腳本有隨機性失敗(`page.click()` 在 React hydration 完成前就送出點擊,事件處理器還沒掛上,訊息送不出去)**——修正方式是先 `waitForSelector` 等一個確定是 client-side render 才會出現的元素(心跳波形 SVG)再互動,這個坑值得記錄:**headless 瀏覽器自動化測試在互動前一定要等到明確的 client-hydration 完成訊號,不能只等 `networkidle`**。實測截圖確認:送出「謝謝你,我今天好開心!」後,親密度/情緒標籤即時更新為「愉悅」,立繪同步切換成上揚嘴角+彎月眼+音符特效;連續四次取樣 canvas 畫面內容互不相同,證實眨眼/呼吸的常駐動態確實在跑。
> - **Live2D Cubism 介面預留**:`TachieRenderer`(`mount`/`applyExpression`/`destroy`)是渲染器的抽象介面,`PixiTachieRenderer` 是目前唯一實作;程式碼註解明確標註「日後若要接上真正的 Live2D Cubism SDK(授權需人工確認),只需要另外寫一個同樣實作這個介面的 `Live2DTachieRenderer`」——**完全沒有引入任何 Live2D SDK 或其授權條款**,這條路徑目前純粹是架構上的佔位,尚未有任何後續動作,授權議題留給之後真的要導入時人工確認。
### P. 語音子系統
- [x] **P-1 Voice Sheet(S)**:定義角色音色設定(基礎音色、預設語速、音域幅度、口頭聲響庫、禁則)並掛到 `Character`。驗收:可為種子角色寫入完整 Voice Sheet。依據:§角色音色設定(Voice Sheet)。
- [x] **P-2 非語言發聲與節奏(S)**:依情緒自動插入「嗯?」「唔……」嘆氣、輕笑等非語言發聲與停頓(重大話題前停 1~2 秒);親密度影響音量與氣音比例;低親密度不打斷使用者。驗收:不同情緒下插入的聲響與停頓不同。依據:§對話節奏與非語言發聲。
- [x] **P-3 語音標記層(M)**:情緒狀態機輸出直接驅動韻律參數(語速、音高、音量、句尾走向),實作 wiki§情緒 → 韻律對照 的六種情緒設定。驗收:同一句話在不同情緒下產生不同韻律標記。依據:§情緒 → 韻律對照。
- [x] **P-4 TTS Provider 抽象(M)**:比照 `LLMProvider` 定義 `TTSProvider`,先實作 Mock(輸出韻律標記與佔位音檔),真實供應商待人工確認後再接。驗收:切換 Provider 不動呼叫端。依據:技術選型「TTS 服務:情緒韻律語音」。
- [x] **P-5 語音輸入與副語言分析(M)**:STT 轉文字之外,另抽取語速、音量、顫抖、停頓等副語言特徵送入情緒標記器(「文字說沒事但聲音在抖」判為負向)。驗收:同一段文字配不同副語言特徵得到不同情緒標記。依據:§語音管線架構「雙向都有語音」。
- [x] **P-V 階段驗證(XS)**:`npm run restart && npm run smoke -- P`(P.mjs:韻律對照、非語言發聲插入、副語言特徵影響情緒標記)。
> **實作記錄(P 群組)**:
> - 新模組放在 `apps/api/src/voice/`,架構完全比照 F 群組:`TTSProvider`/`TTS_PROVIDER` token/`MockTTSProvider`/`RealTTSProvider`(尚未實作、被呼叫才丟例外)/`TTS_PROVIDER=mock|real` 環境變數切換工廠函式,逐一對應 `LLMProvider`/`LLM_PROVIDER`/`MockProvider`/`ClaudeProvider`/`LLM_PROVIDER=mock|claude`。**這組抽象本身沒有新設計,純粹是同一個模式的第二次套用**,這正是 F-1 當初把 Provider 抽出介面的目的:之後任何生成式外部服務要接進來,都走同一套「先做 Mock、真的供應商待人工確認、環境變數切換」的節奏。
> - **P-1 Voice Sheet 用獨立資料表(同 EmotionState 的 1:1 關聯),不是塞進 `Character` 本體**——口頭聲響庫/禁則是字串陣列,但 SQLite 的 Prisma connector 不支援原生字串陣列欄位,改用 JSON 字串欄位(`verbalTicsJson`/`forbiddenSoundsJson`)+ service 層 `JSON.parse`/`JSON.stringify`,對外一律回傳/接收真正的 `string[]`,呼叫端不需要知道底層是 JSON 字串。預設值依性格原型推導(`defaultVoiceSheetFor`),跟 G-5「原型 → 種子預設」、I-1「原型 → 作息表種子」是同一個模式的第三次套用。
> - **P-3/P-4 的韻律縮放沿用 O-4 的設計原則,但縮放的依據換成 Voice Sheet 的音域幅度而不是外顯度**:`pitchShiftSemitones = 情緒對照表的基準音高變化 × (角色音域幅度 / 4)`——三無角色音域幅度只有 0.5(對照組的通用預設是 4),同樣的情緒事件換算出來的音高變化只有基準值的 1/8,具體實現「三無角色近乎單音」。**這裡刻意不重複使用 `expressiveness`**(那是表情/情緒外顯度,跟音域幅度是概念上不同的兩個原型參數,各自獨立設定,即使數值上有意的相關性)。
> - **P-2 停頓機制踩到一個實際的 bug**:一開始用「目前主導情緒的數值 / 100」當作停頓長度的訊號強度,但完全沒考慮到「平靜」狀態下 `calm` 欄位預設就是 100——導致角色明明什麼事都沒發生(平靜狀態),卻被誤判成「強度 1.0 的重大情緒事件」,觸發本該只留給強烈情緒的 1~2 秒長停頓。**平靜不是情緒事件,是情緒事件的缺席**,修正方式是讓 `emotionTag === "CALM"` 時強制把訊號強度視為 0,只有真正的六種「有事發生」情緒(愉悅/低落/警戒/害羞/彆扭)才會依強度觸發長停頓——這個坑跟 G-4 早期「情緒觸發閾值只縮放強度、沒縮放要不要觸發」是同一類錯誤(把「數值存在」跟「訊號有意義」搞混),值得記錄成通用提醒:**任何用「情緒維度數值」當強度訊號的地方,都要先排除 CALM 這個「預設/缺席」維度,不能直接套用同一個公式**。
> - **P-5 副語言訊號的優先序刻意排在 I-5 作息基線之前**:`EmotionService.processInput` 現在的訊號決定順序是「文字本身觸發 → 副語言訊號(P-5)→ 作息基線(I-5)→ 維持平靜」——副語言是「這一輪輸入本身」的直接訊號,理應比「這個時段通常會怎樣」的基線更優先,但兩者都只在文字沒有觸發任何訊號(CALM)時才會生效,不會蓋掉文字本身已經觸發的訊號。**手動測試時發現一個容易踩的陷阱**:如果角色當下已經有一個很強的主導情緒(例如 JOY 90),副語言訊號算出來的 SAD 不一定能立刻蓋過去(情緒狀態機本身的轉移規則決定要不要真的切換主導情緒,跟訊號有沒有正確算出來是兩件事)——寫測試時要先把情緒重置到 CALM 基準,才能乾淨地驗證副語言訊號本身有沒有被正確送進情緒標記器,不要跟情緒狀態機的轉移阻力混在一起判斷。
> - **P-5 沒有做真正的語速/音量/顫抖偵測**——副語言特徵目前是結構化輸入(呼叫端直接給數值),跟 M 群組「場景切分」、N 群組「互動正負權重」是同一種「先用結構化輸入代替真實訊號處理」的簡化,理由相同:真正的音訊特徵抽取需要處理實際音訊串流,超出目前系統的 Mock 階段範疇,機制(訊號合成規則、與文字訊號的優先序、餵進情緒標記器)先做對,之後接上真的語音辨識/音訊分析只需要替換這個輸入來源。
> - **全量回歸執行到 O 群組時出現一次 `fetch failed`,重新單獨執行 O 群組立刻全綠**——跟 J 群組踩過的「重啟瞬間 keep-alive socket 失效」是類似的環境性瞬斷(這次沒有服務重啟動作,單純是連續執行 15 個群組、主機負載升高時的暫態網路錯誤),不是 P 群組程式碼引入的迴歸;已重新單獨驗證 O 群組與完整 A~P 序列(除了這次瞬斷)皆為全綠,記錄於此供之後遇到類似狀況時參考,不需要當成真的 bug 去追。
### Q. APP(React Native + Expo)
- [x] **Q-1 Expo 專案建立(S)**:於 `apps/mobile` 建立 Expo 專案並接上 `packages/shared`(型別與 API client 共用)。驗收:APP 能呼叫 api 的 `/health` 並顯示結果。依據:技術選型「React Native + Expo」。
- [x] **Q-2 行動版佈局與分頁籤(M)**:立繪置頂+對話流覆蓋的單欄佈局,底部分頁籤(聊天/角色/日常/設定)。驗收:四個分頁可切換且聊天分頁可完成一輪對話。依據:§三種載體的版面「APP」。
- [x] **Q-3 推播通知(M)**:接上 Expo 推播,支援提醒觸發、角色主動訊息與鎖屏通知。驗收:J 群組建立的提醒能以推播送達。依據:§三種載體的版面「支援推播(提醒、她的主動訊息)與鎖屏通知」。
- [x] **Q-V 階段驗證(S)**:`npm run restart && npm run smoke -- Q`(Q.mjs:API 契約層測試),並在模擬器或實機啟動 APP 完成一輪對話與一次推播(需人工確認可用裝置)。
> **實作記錄(Q 群組)**:
> - `apps/mobile` 用 `create-expo-app` 的 `blank-typescript` 範本建立,改用 **Expo Router**(檔案系統路由)取代範本內建的單一 `App.tsx`——跟 `apps/web` 的 Next.js App Router 是同一套心智模型(`app/(tabs)/` 底下每個檔案就是一個分頁),三端統一走「檔案結構=路由結構」的慣例。套件命名對齊既有慣例改成 `@kokorone/mobile`,`main` 指到 `expo-router/entry`。
> - **四個分頁(聊天/角色/日常/設定)都是真的打 API、不是靜態畫面**:角色分頁打 `/characters`、日常分頁打 `/schedule/:id/info`、設定分頁打 `/health` 與推播註冊、聊天分頁完整重用 H 群組/O 群組已經驗證過的對話+表情查詢流程(`sendChatMessage` → 更新關係/情緒 → 查詢 `/tachie/.../expression` → 延遲約 220ms 才顯示文字,跟 `apps/web/app/chat/ChatView.tsx` 是同一個節奏,只是元件換成 React Native 的 `View`/`Text`/`TextInput`)。
> - **立繪本體目前是 React Native 的佔位色塊,沒有把 O 群組的 PixiJS 渲染器搬過來**:PixiJS 的 `Application`/`Graphics`/`Text` 是瀏覽器 DOM/Canvas API,React Native 沒有這層——真的要在 APP 上重現分層合成的立繪,需要 `react-native-skia` 或類似的原生繪圖套件,屬於超出本群組時間範圍的額外工作,先用一個依情緒 `magnitude` 微調透明度的色塊佔位,保留「這裡之後要接真的立繪渲染」的位置。
> - **本環境沒有 Android/iOS 模擬器(無 Android SDK、非 macOS),所以「在模擬器或實機啟動 APP」這項驗收如實無法在這個環境完成**——這正是 Q-V 驗收文字本身就寫明「需人工確認可用裝置」的部分。改用 `expo start --web`(Expo/React Native Web 支援)+ 在 scratchpad 暫裝的 `playwright-core`(同 O 群組的作法)實際驅動一個無頭瀏覽器跑過完整流程:四個分頁都能切換、都能打到真的 API 並顯示回應、聊天分頁完成一輪對話後親密度與情緒即時更新、推播按鈕在沒有真實裝置時會走「未授權/非裝置」的訊息分支而不是整個掛掉。**這不是原生 APP 的完整驗證(沒有測到原生模組、沒有測到真的推播送達),但驗證了「畫面邏輯、導覽、API 串接」這一層的正確性**,比完全沒驗證好得多,且跟 O 群組的瀏覽器驗證方法論一致。
> - **踩到一個真的 CORS 問題**:`expo start --web` 預設跑在 `localhost:8081`,但 `apps/api/src/main.ts` 的 CORS 設定只放行 `WEB_ORIGIN`(預設 `localhost:3100`,即 Next.js 網頁版),瀏覽器直接擋掉行動版網頁對 API 的請求——這個限制只在「用瀏覽器測 Expo 網頁版」這個情境下存在,原生 iOS/Android APP 不受 CORS 約束(CORS 是瀏覽器機制)。修正方式是讓 `enableCors` 的 `origin` 同時放行 `WEB_ORIGIN` 與 `localhost:8081` 兩個來源——**這是一個小但永久的後端設定變更**(不是只在驗證時暫開、驗證完就關),因為之後任何人想用 `expo start --web` 快速檢查行動版而不開實體裝置,都會需要這條放行。
> - **Q-3 推播的 Provider 抽象是 F-1 LLMProvider/P-4 TTSProvider 模式的第三次套用**(`PushProvider`/`PUSH_PROVIDER`/`MockPushProvider`/`ExpoPushProvider`/`PUSH_PROVIDER=mock|expo`),但跟 LLM/TTS 不同的地方是:**Expo 推播 API 不需要另外挑供應商待人工確認**——「React Native + Expo」這個技術選型本身就決定了推播走 Expo 的服務,且匿名發送不需要金鑰,所以 `ExpoPushProvider` 是直接可以打出真實 HTTP 請求的完整實作,不是像 `RealTTSProvider`/`ClaudeProvider` 那樣的空殼——只是在這個環境沒有真實裝置 token,實際呼叫 Expo 端會回報 token 無效,這是預期行為。
> - **J 群組整合**:`TaskTriggerService.fire()` 現在觸發訊息組好之後,會呼叫 `PushNotificationService.sendToUser`,用同一句觸發訊息當推播內容——沒有註冊任何裝置 token 的使用者,`sendToUser` 單純回報 `attempted: 0`,不影響任務本身照常觸發。**新增了 `PushNotificationLog` 這張表記錄每次推播嘗試(成功或失敗皆記)**,但它只綁 `userId` 不綁 `characterId`,不會隨著 `Character` 被刪除 cascade 清掉——`J.mjs` 因此補了一行清理,否則每次跑 J 群組的迴歸測試都會在這張表留下不會消失的殘留資料。
> - **全量回歸執行到 D/J 群組時各出現一次瞬斷**(`fetch failed`/重啟健康檢查逾時 60 秒),當時主機負載飆到 41.77——跟前面幾個群組記錄過的環境性瞬斷是同一類狀況,負載降下來後單獨重跑兩個群組都立刻全綠,不是 Q 群組程式碼引入的迴歸。
### R. 正式基礎設施遷移
- [ ] **R-1 遷移到 pnpm + Turborepo(S)**:安裝 pnpm,將 npm workspaces 轉為 pnpm workspace + Turborepo pipeline,`npm run restart`/`npm run smoke` 的對外介面保持不變(改為對應的 pnpm 腳本並保留同名入口)。驗收:既有全部冒煙測試在新工具鏈下仍全綠。依據:技術選型「Monorepo:Turborepo + pnpm + TypeScript」。
- [ ] **R-2 Redis 接入(S)**:情緒狀態機的即時讀寫與 session 快取改走 Redis。驗收:重啟 api 後進行中的情緒狀態不遺失。依據:技術選型「Redis:情緒狀態機的即時讀寫、session 快取」。
- [ ] **R-3 ClaudeProvider 實作(S)**:把 F-2 的空殼實作為真實 Claude API 呼叫,Mock 模板庫轉為 few-shot 範例與回歸測試基準;`LLM_PROVIDER=claude` 可正常對話。驗收:兩種 Provider 皆能跑完整管線且引擎程式碼無改動。依據:§LLM Provider 抽象層「第二階段」。
- [ ] **R-4 部署文件(S)**:整理環境變數清單與部署路徑(K8s 或雲託管)說明,寫入 README。驗收:依文件可在乾淨環境重現啟動。依據:技術選型「部署:Docker Compose(開發)→ K8s 或雲託管(正式)」。
- [ ] **R-5 PostgreSQL + pgvector 遷移(M)**:Prisma provider 由 sqlite 改為 postgresql,撰寫資料遷移腳本,`MemoryRetriever` 增加 pgvector 語意檢索實作並切換為預設。驗收:既有資料完整遷移,檢索改走向量後 C 群組冒煙測試仍全綠。依據:技術選型「PostgreSQL + Prisma」「pgvector」。
- [ ] **R-6 BullMQ 取代 in-process 佇列(M)**:`JobQueue` 換成 BullMQ 實作,睡眠固化、離線事件生成、提醒排程全部走佇列。驗收:J-7 的「重啟後提醒不遺失」在新實作下仍通過。依據:技術選型「BullMQ:睡眠固化/離線事件/提醒排程」。
- [ ] **R-7 Docker Compose 開發環境(M)**:以 compose 編排 api/web/postgres(pgvector)/redis,`npm run restart` 改為停止並重建 compose 服務後等待健康檢查,對外指令介面不變。驗收:`npm run restart` 一鍵重啟整組服務並通過健康檢查。依據:技術選型「Docker Compose(開發)」。
- [ ] **R-V 全量驗證(M)**:`npm run restart` 後依序執行 A~R 全部冒煙測試(`npm run smoke -- all`),全部通過才算本清單完成;任何一項失敗須修復後重跑全量。
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "*.tsbuildinfo", ".next/**", "!.next/cache/**"]
},
"lint": {
"dependsOn": ["^build"]
}
}
}