Files
Kokorone/todo.md
T
JefferyandClaude Sonnet 5 eccdcba45c feat: 完成 N 群組 — 後日談模式
錨定點抵達已出版內容盡頭時切入後日談區間;時間流速(即時同步/緩速/凍結,
另加一個非文件既定的 FAST 選項供一般角色使用)對後日談推演成年角色強制
上限 1:1,重用 L 群組 RomanceEligibilityService 的 isEpilogueAdult 判斷。
性格漂移僅在正史已有 >=2 筆 PersonalityTraitSnapshot 建立趨勢時才位移,
無證據則鎖定 0(不憑空轉向),原型字串本身從不被觸碰。人生階段推演與
角色群同步成長重用 M 群組的 Scene/ProjectionService(推演事件其實就是
sourceType=EPILOGUE 的場景,在場者投影後自然敘述互相呼應);重大轉折
需要正史目標關鍵字對應才生成,並以 EpilogueMilestoneLog 節流頻率。
正史回收(移除被取代的推演場景、匯入官方新場景並強制投影、互動記憶
結構性保留、記憶修正事件敘事化消化)與分支保留(永久標記、擋下後續
回收)。

修正兩個真實 bug:reclaim 原本借用 importScene 的錨定閥門,導致回收
回來的正史內容反而不被投影(後日談的『現在』本來就走在錨定點之前);
分支保留擋下重新匯入時用裸 Error 導致 500,改為 ConflictException。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 17:18:53 +08:00

98 KiB
Raw Blame History

model, model_alias, model_reason, analyzed_by, analyzed_at, scope
model model_alias model_reason analyzed_by analyzed_at scope
claude-sonnet-5 sonnet 本清單的來源(Kokorone 系統架構計畫 wiki)已把每個子系統的機制、資料結構、對照表與判定規則寫到規格層級,清單本身也已拆解到「動詞+對象+驗收條件」的可直接動手粒度,實作時的主要工作是照規格落地程式碼、逐階段補齊介面與測試,而不是重新做架構決策;工作量體大、階段多、需長時間連續產出,選擇具備 claude-opus-5[1m] 2026/08/12 17:27:05 /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. 執行順序

群組之間有強制先後,不可跳做:

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. 專案骨架與可重啟的服務

  • 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 會遷移。
  • A-2 TypeScript 基礎設定(XS):建立根 tsconfig.base.json(strict: true),各 workspace 以 extends 繼承;建立 packages/shared 空套件並匯出一個型別驗證編譯鏈路。驗收:npx tsc -b 零錯誤。依據:技術選型「TypeScript:三端共用型別」。
  • A-3 統一日誌模組(S):於 packages/shared/src/log.ts 實作 [yyyy/MM/dd HH:mm:ss][階段][等級]: 訊息 格式輸出(時區 Asia/Taipei,等級限 INF/WRN/ERR/TRC/DBG,一行一則),api 與 web 的伺服端輸出一律走此模組。驗收:啟動 api 時輸出符合格式的啟動訊息。
  • A-4 建立 apps/api 最小 NestJS 服務(S):實作 GET /health 回 { status: 'ok', version, uptime },連接埠讀 PORT(預設 3001)。驗收:curl localhost:3001/health 回 200 且含 status: ok。依據:技術選型「後端 NestJS + Socket.IO」。
  • A-5 建立 apps/web 最小 Next.js 頁面(S):App Router + Tailwind,首頁顯示「心音 Kokorone」標題與心跳波形 SVG,套用色票 心動粉 #FF7E9D/暮空紫 #8C7AE6/晨霧白 #FFF8FA/夜空藍黑 #1A1430/墨字 #3A3242,並以 CSS 變數實作亮/暗模式(暗色模式為一級公民)。驗收:http://localhost:3000 顯示標題與波形,切換系統深色模式配色正確。依據:主視覺與資產§色彩、§視覺母題。
  • 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 不會殘留重複行程且皆成功。依據:使用者要求「每完成一個階段就重新啟動服務」。
  • A-7 冒煙測試框架(M):實作 scripts/smoke/run.mjs,以 npm run smoke -- <群組代號> 執行 scripts/smoke/<群組代號>.mjs;建立 scripts/smoke/A.mjs(檢查 /health 回 200、web 首頁含「心音」字樣),失敗以非零狀態結束。驗收:npm run smoke -- A 全綠。
  • 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. 資料層與核心領域模型

  • 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。
  • B-2 共用型別套件(S):packages/shared 匯出角色、記憶、情緒、關係的 TypeScript 型別,api 與 web 皆從此匯入,不各自定義。驗收:api 與 web 皆能編譯通過且無重複型別定義。依據:技術選型「packages/shared:型別/狀態邏輯/API client 三端共用」。
  • B-3 種子資料(S):prisma/seed.ts 建立一位測試使用者與一位原創測試角色(元氣型,含說話方式與喜惡),供後續所有階段驗證使用。驗收:npx prisma db seed 後 GET /characters 回傳該角色。
  • B-4 作品與角色資料表(M):定義 Work(書名/卷數進度/世界觀/作品進度錨定點)、Character(角色設定表七欄位:基本資料、背景故事、性格原型、喜好厭惡、目標執念、說話方式、人際初始值;另含來源=原創/既有作品、建置狀態=候補/已建置)、CharacterAlias(正式名/暱稱/他人稱呼)。驗收:migrate 成功且可寫入讀出完整角色設定表。依據:§角色設定表(Character Sheet)、§作品資料結構「角色名冊是作品的戶口」。
  • B-5 記憶資料表(M):定義 EpisodicMemory(內容、發生時間、情緒標籤類型+強度、提取次數、最後提取時間、來源=互動/離線生成/原作萃取、權重)、SemanticMemory(去情境化事實、對象)、ProceduralRule(情境→回應模式、權重)。驗收:三表可寫入讀出且 metadata 欄位齊全。依據:§核心機制設計 1「每筆記憶附 metadata:時間戳、情緒標籤、提取次數」。
  • B-6 情緒與關係資料表(M):定義 EmotionState(各情緒維度值、最後更新時間,支援平靜/愉悅/低落/警戒/害羞/彆扭)、Relationship(intimacy 0100、trust 0100、first_met、last_interaction、interaction_count、關係階段)、SentimentLedgerEntry(date、event、weight,允許正負)。驗收:可完整重現 wiki§關係檔案資料結構的 YAML 範例欄位。依據:§關係檔案資料結構(示意)。
  • 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. 記憶子系統

  • C-1 工作記憶(S):實作 session 對話上下文緩衝,帶 token 上限與溢位裁切策略(保留最近與高情緒段落)。驗收:超過上限時最舊的低情緒段落先被裁掉。依據:§腦區→聊天系統元件對照「工作記憶=對話上下文視窗,有 token 上限」。
  • C-2 對話期只寫工作記憶(S):明確禁止對話流程中寫入長期記憶表,所有長期寫入只能由固化程序觸發。驗收:一輪對話後三張長期記憶表筆數不變。依據:§核心機制設計 1「對話中不即時寫長期記憶」。
  • C-3 排程抽象層(S):定義 JobQueue 介面(now / schedule(at) / every(cron)),以 in-process 計時器實作 InProcessJobQueue,並在 DI 容器註冊;session 結束事件推入固化工作。驗收:排入 5 秒後的工作會準時執行。註記:R-4 換成 BullMQ 時只換實作、不動呼叫端。依據:技術選型「BullMQ:睡眠固化/離線事件/提醒排程」。
  • C-4 睡眠固化程序(M):實作 consolidate(sessionId)——回顧整段對話,只有情緒強度高於門檻或被重複提及的內容才寫入情節/語意/程序記憶,並寫入完整 metadata。驗收:一段含「一件高情緒事件+數句閒聊」的對話固化後,只有高情緒事件進 EpisodicMemory。依據:§核心機制設計 1「Session 結束觸發睡眠固化」。
  • C-5 遺忘與提取即改寫(M):固化程序中對「情緒強度低且長期未被提取」的記憶降權或刪除;每次檢索命中即更新提取次數與最後提取時間。驗收:模擬時間推進後低權重舊記憶被清除,常被提取者保留。依據:§核心機制設計 2「記憶遺忘(自然衰減)」。
  • C-6 記憶檢索器(M):定義 MemoryRetriever 介面並實作關鍵字版本,排序權重=關鍵字相關度+時間近因+情緒權重+(E-6 補上的)關係對象加權。驗收:查詢命中相關情節記憶且排序符合權重設計。註記:R-2 會加上 pgvector 語意檢索實作。依據:技術選型「pgvector:記憶語意檢索」。
  • 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. 情緒子系統

  • D-1 情緒標記器(S):實作 EmotionTagger 介面與規則版實作(關鍵字+輸入特徵+角色觸發閾值),輸出情緒類型與強度分數。驗收:正向/負向/衝突語氣輸入分別得到對應標記。依據:§聊天系統架構圖「情緒標記器:為輸入評估情緒強度」。
  • D-2 時間衰減(S):以半衰期函數隨時間回歸平靜,衰減速度由角色參數決定;跨 session 保留殘留情緒。驗收:模擬經過數小時後情緒值回落,重新開啟 session 仍讀得到殘留。依據:§情緒狀態機「跨 session 保留情緒殘留……但隨時間衰減」。
  • D-3 情緒→回應風格輸出(S):把當前情緒轉為回應參數(語氣、句長、主動性),提供給 F 群組的上下文組裝器。驗收:愉悅時參數指向「多話」、低落時指向「簡短」。依據:§情緒狀態機「情緒狀態影響:回覆語氣、用詞選擇、主動性」。
  • D-4 情緒狀態機(M):實作平靜/愉悅/低落/警戒的轉移規則(正向互動、負向事件、偵測衝突語氣、確認無威脅、衝突持續),並加入害羞與彆扭兩個狀態(供 G-4 可愛度行為使用)。驗收:依 wiki 狀態圖的每條轉移邊都有對應測試通過。依據:§情緒狀態機 stateDiagram、§可愛度設計「情緒引擎新增『羞』狀態」。
  • 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. 關係子系統

  • E-1 親密度與信任讀寫(XS):實作關係檔案的建立(未知對象預設「禮貌+防備」初始值)與讀寫服務。驗收:新對象首次互動自動建檔。依據:§關係如何影響對話行為「未知 → 建立新關係檔案,預設:禮貌+防備」。
  • E-2 關係帳本與負向偏誤(S):互動事件寫入 SentimentLedgerEntry,負向事件權重放大係數可由角色參數調整。驗收:同等級正負事件各一次後,淨值為負。依據:§關係如何被大腦儲存與更新「負向偏誤:一次背叛抵銷多次善意」。
  • E-3 親密度分層(S):實作 陌生 0-19 /認識 20-39 /朋友 40-59 /摯友-曖昧 60-79 /羈絆 80-100 五階段與跨階解鎖旗標。驗收:跨越門檻時發出可被其他子系統訂閱的事件。依據:§動漫式關係進展(好感度系統)。
  • E-4 關係時間衰減(S):久未互動自動降親密度,重逢時開場語氣可讀取「久未見」旗標。驗收:模擬長時間未互動後親密度下降且旗標為真。依據:§映射到聊天系統:關係模型「關係衰減:時間衰減函數」。
  • E-5 關係加權檢索接點(S):把「與當前對象相關」納入 C-6 檢索權重。驗收:與 A 對話時 A 相關記憶排序優先於同分數的無關記憶。依據:§與其他子系統的整合「關係 × 記憶」。
  • E-6 意圖推測(M):依對象歷史互動模式解讀當前訊息(同一句話,高親密度判為玩笑、低親密度判為冒犯),輸出解讀標記供生成層使用。驗收:相同輸入在高/低親密度下得到不同解讀標記。依據:§映射到聊天系統:關係模型「心智理論 → 意圖推測」。
  • 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 抽象

  • F-1 LLMProvider 介面(S):定義 generate(context) 與 stream(context),輸入為組裝好的上下文物件(人設、情緒狀態、檢索記憶、關係參數、對話歷史),輸出為含動作描寫標記的回應結構。驗收:型別定義於 packages/shared 且 api 依賴介面而非實作。依據:§LLM Provider 抽象層「切換 Provider 不動引擎任何一行」。
  • F-2 Provider 切換與 ClaudeProvider 空殼(S):以環境變數 LLM_PROVIDER=mock|claude 決定注入哪個實作,ClaudeProvider 先拋「尚未實作(R-6)」。驗收:設為 claude 時啟動即以 ERR log 明確告知未實作。依據:§LLM Provider 抽象層流程圖。
  • F-3 輸出過濾層(S):實作前額葉抑制層——安全檢查、語氣調節、角色禁則詞彙過濾,位於 Provider 之後、回覆之前。驗收:含禁則詞的模板輸出被攔截或改寫。依據:§腦區對照「前額葉(抑制)=輸出過濾」。
  • F-4 雙速通道(S):高頻固定問候與明確危險輸入走快速通道(直接套用程序記憶模式),其餘走完整流程。驗收:快速通道回應不觸發記憶檢索(以計數驗證)。依據:§核心機制設計 4「雙速回應(快慢通道)」。
  • F-5 行為強化迴路(S):使用者明確稱讚 → 對應程序記憶模式加權;使用者糾正 → 原模式降權並以修正版取代;重複命中的「情境→回應」自動下沉為慣例。驗收:稱讚後同情境優先選用該模式。依據:§核心機制設計 5「行為強化迴路」。
  • F-6 上下文組裝器(M):把人設、當前情緒、檢索到的記憶、關係參數、對話歷史組裝成統一上下文物件,並記錄「本次注入了哪些記憶」供除錯。驗收:一次對話可輸出完整組裝內容快照。依據:§LLM Provider 抽象層「上下文組裝邏輯先在 Mock 期打磨定型」。
  • F-7 MockProvider(M):依「性格原型 × 情緒狀態 × 親密度」從模板庫選填回應,支援固定 seed 產生可重現輸出,並輸出動作描寫標記(如 *臉紅撇過頭*)。驗收:同 seed 兩次輸出完全相同;不同情緒/親密度輸出不同模板。依據:§LLM Provider 抽象層「MockProvider 行為」。
  • 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. 角色人格層

  • G-1 性格原型參數表(S):建立 傲嬌/冷淡/天然呆/元氣/大小姐/三無 六原型的參數組(情緒觸發閾值、情緒外顯度、信任成長速度、特徵行為旗標),存為可版本化的設定檔並掛到 Character。驗收:切換原型後同一輸入產生不同情緒與語氣參數。依據:§性格原型 → 引擎參數對照。
  • G-2 語言風格層(S):實作第一/第二人稱、口癖、語尾、稱呼系統(依親密度切換:您/同學 → 名字 → 暱稱)與禁則清單,作為輸出層後處理。驗收:親密度跨階後稱呼自動改變。依據:§語言風格層(輸出模板)。
  • G-3 外顯函數(S):實作內部親密度與外顯表現的轉換(傲嬌為反向表達,「傲嬌值」=內外差值,隨內部值升高而縮小)。驗收:傲嬌角色內部親密度上升時,外顯敵意先升後降。依據:§核心原則「傲嬌不是沒有好感,而是好感的外顯函數是反向的」。
  • G-4 反差萌與稀有度控制(S):反差行為=性格原型的例外規則,觸發條件(親密度門檻+情緒狀態+機率)滿足才發生,觸發後進入冷卻期,且永不常態化。驗收:冷卻期內同類反差不再觸發。依據:§反差萌的參數化「稀有度規則」。
  • G-5 角色建立流程(S):提供由角色設定表產生「初始語意記憶(關於自己的事實)+初始情節記憶(背景故事,帶情緒標記)+情緒參數+關係初始值」的建立指令。驗收:建立新角色後三類初始資料齊備。依據:§分層設計:引擎與角色分離的資料流。
  • G-6 可愛度行為機制(M):實作害羞、撒嬌(親密度≥60)、鬧彆扭(被哄後快速恢復)、吃醋(偵測第三者好感訊號)、記住小事(使用者瑣事額外加權並日後主動提起)、笨拙的努力、稱呼進化、專屬揭露(信任≥80 解鎖深層記憶並明示「只跟你說過」)八種行為的觸發條件與節奏控制(撒嬌頻率克制、彆扭不超過兩三輪)。驗收:每種行為都有對應的觸發測試通過。依據:§具體行為機制與引擎實作、§可愛的節奏控制。
  • 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. 網頁對話介面(第一個可實際使用的垂直切片)

  • H-1 對話 API 與 session 生命週期(S):POST /chat/:characterId 走完整管線(標記情緒 → 檢索記憶 → 組裝 → 生成 → 過濾),POST /session/:id/end 觸發睡眠固化。驗收:一輪完整對話回傳回應、情緒狀態與親密度變化。
  • H-2 情緒晶片與親密度顯示(S):對話畫面顯示當前情緒(愉悅/害羞/彆扭…)與親密度數值。驗收:畫面數值與 API 回傳一致並即時更新。依據:主視覺與資產§電腦網頁 mockup 的「親密度 62」「愉悅」晶片。
  • H-3 動作描寫標記渲染(S):把 *…* 標記以暮空紫小字呈現,與台詞區分。驗收:含動作描寫的回應在畫面上樣式正確。依據:主視覺與資產 mockup .bubble .act 樣式。
  • H-4 心跳波形母題(S):載入動畫為波形由左至右畫出、親密度以波形振幅呈現、通知紅點為心跳脈動;尊重 prefers-reduced-motion。驗收:三處母題皆可見且降低動態偏好下不動畫。依據:主視覺與資產§視覺母題。
  • H-5 對話頁版面(M):電腦版雙欄(左對話流、右大幅立繪區,先以佔位圖形);行動版單欄、立繪半身像置頂佔 40% 為背景層、對話流覆蓋其上;同一份 RWD 程式。驗收:桌面與 720px 以下寬度各自呈現正確版面。依據:§三種載體的版面。
  • 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)與對話頁,振幅由親密度(0100)換算縮放係數 0.41.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. 生活作息與離線生活

  • I-1 作息表資料結構(S):平日/週末/假期各一份時段表,欄位含各時段狀態(睡眠/忙碌/半忙碌/空閒)、固定行程、特殊日(考試週、生日、紀念日)。驗收:可為種子角色寫入一份高中生作息表。依據:§作息表設計表格。
  • I-2 時段狀態→回應可用度(S):空閒正常回應、半忙碌簡短、忙碌延遲或不回並事後補一句、睡眠不回、剛醒/睡前加上迷糊演出。驗收:模擬各時段輸入得到對應行為。依據:§作息如何影響對話。
  • I-3 時間感知(S):角色知道現在幾點、星期幾、季節,開場語與話題隨之調整。驗收:不同時段開場語不同。依據:§作息如何影響對話「時間感知」。
  • I-4 破例規則(S):深夜還陪你聊(親密度高+使用者情緒低落)、上課偷回訊息(親密度極高+緊急)、為你調整行程(羈絆級)三種破例,且必須稀有。驗收:條件不足時不破例,條件滿足時破例並記入關係帳本。依據:§破例即訊號:作息 × 親密度。
  • I-5 作息驅動情緒基線(S):社團剛結束「累」、放假日基線偏高等日內波動。驗收:同一輸入在不同時段得到不同情緒起點。依據:§作息如何影響對話「狀態殘留」。
  • I-6 離線事件生成(M):依時段+性格+近期劇情,於下次對話開始時按需生成合理日常小事,寫入低權重情節記憶並作為話題來源;生成內容須符合作息表與角色設定。驗收:隔一段時間再對話,角色會主動提起今天發生的事,且事件不違反其作息。依據:§離線生活:不在線時她在活著。
  • 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. 委託子系統

  • J-1 任務資料表與解析(S):Task(類型=單次/週期/條件/查詢整理/代辦追蹤、觸發時間或條件、內容、狀態),並實作自然語句解析成任務。驗收:「三點提醒我開會」建立正確的單次提醒。依據:§委託類型表。
  • J-2 接案演出層(S):依性格原型輸出接案台詞(傲嬌嘴硬、元氣熱情、冷淡一個「嗯」),但引擎層必定接受任務——拒絕只是演出。驗收:六原型各有對應台詞且任務皆成功建立。依據:§接案的角色演出「該接的最後都會接」。
  • J-3 觸發演出(S):提醒觸發時語氣隨作息(深夜壓低、早晨迷糊),內容帶記憶前因後果(「資料你昨天說還沒做完,帶了嗎?」)。驗收:觸發訊息含相關記憶引用。依據:§提醒的觸發演出「提醒不是鬧鐘,是記得前因後果的人在提醒你」。
  • J-4 逾期追擊(S):使用者無反應時依性格追擊(元氣連環、冷淡隔十分鐘補一句、傲嬌口是心非再傳一次)。驗收:模擬無回應後產生符合原型的追擊訊息。依據:§提醒的觸發演出「逾期追擊」。
  • J-5 委託回饋迴路(S):每次委託與完成寫入關係帳本(正向事件),被感謝提升情緒、被忽略產生小失落;重複同類委託形成默契旗標。驗收:完成委託後親密度與情緒皆有變化。依據:§任務與記憶、關係的迴路。
  • J-6 主動關照(S):偵測使用者待辦訊號並追蹤、作息交叉比對(常熬夜 → 到點主動出現),且頻率克制、不重複同一句。驗收:連續觸發時受頻率上限抑制。依據:§主動關照:沒被委託的提醒。
  • J-7 排程器可靠性(M):走 C-3 的 JobQueue,時間到必觸發(角色設定的迷糊只能表現在演出,不得真的忘記);服務重啟後未觸發的任務仍會被重新排入。驗收:建立提醒後重啟服務,時間到仍準時觸發。依據:§分層:任務引擎與角色演出「排程與執行走確定性系統」。
  • 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. 對話模式:群聊與角色自聊

  • K-1 在場者模型(S):session 支援多角色參與者名冊,角色可讀取「誰在場」。驗收:群聊 session 內每個角色都能取得完整在場名單。依據:§群聊中的行為變化「在場者感知」。
  • K-2 群聊行為變化(S):人前矜持(一對一會撒嬌的角色群聊時收斂、傲嬌更嘴硬)、對不同對象使用不同稱呼與語氣。驗收:同角色在一對一與群聊的同一情境輸出不同。依據:§群聊中的行為變化。
  • K-3 隱私邊界(S):一對一聊過的私密內容,該角色在群聊中不主動洩漏(口風不緊為角色設定的例外)。驗收:標記為私密的記憶不會出現在群聊回應中。依據:§跨模式的記憶連續性「隱私邊界」。
  • K-4 發言權分配(M):每輪計算各角色發言衝動值(被點名/話題相關度/性格基線/情緒狀態/與發言者關係/發言冷卻),超過門檻才發言;沉默也是演出。驗收:三無角色整場只發言少數次、元氣角色發言最多、剛發言者衝動下降。依據:§發言權分配表。
  • K-5 群聊記憶投影(M):群聊記錄為一份共用場景記錄,session 結束時各角色以自身視角萃取記憶,情緒標記可不同。驗收:同一場群聊固化後,兩角色的情節記憶內容與權重不同。依據:§群聊的記憶投影。
  • K-6 角色自聊(M):話題種子(使用者指定/共同記憶抽取/日常情境模板)、發言權沿用群聊機制、空轉偵測(重複與資訊量下降)注入轉折或收尾、輪數上限硬停損、使用者插話即切換群聊。驗收:無人插話時能自然收尾且不超過輪數上限。依據:§模式三:角色自聊(旁觀模式)。
  • 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. 戀愛關係軸與內容尺度

  • L-1 雙軸模型(S):新增心動值欄位(0~100),與親密度獨立:親密度日常穩定累積且衰減慢,心動值事件驅動、衰減快、易回落。驗收:純聊天量只推升親密度、不推升心動值。依據:§雙軸模型「純聊天量刷不出戀愛」。
  • L-2 告白事件(S):答覆由當時心動值與性格決定,可以被拒絕;拒絕不清空累積但進入尷尬期,需修復事件回溫,連續失敗真的傷關係;拒絕後立即再推進觸發警戒與心動值下降。驗收:心動值不足時告白被拒且進入尷尬期狀態。依據:§拒絕與失敗的處理。
  • L-3 性格進展曲線(S):六原型各自的戀愛軸走勢(傲嬌敵意與心動同步上升、冷淡極緩線性、元氣卡曖昧期、大小姐階梯跳升、三無幾無外顯)。驗收:相同事件序列在不同原型下產生不同曲線。依據:§性格決定進展曲線。
  • L-4 親密表現分層(S):在意~曖昧、戀人初期、戀人穩定期、深度伴侶各階段開放的親密表現不同;每個「第一次」做成事件級演出(高權重記憶);跳級要求得到該階段該有的退縮反應並記入帳本。驗收:階段未到時的跳級要求被拒且留下帳本紀錄。依據:§親密表現與關係階段的對應。
  • L-5 心動事件與階段門檻(M):實作五類心動事件(共同經歷高情緒事件、被理解的瞬間、展現她欣賞的特質、恰到好處的距離感、失望事件回落)與 朋友→在意→曖昧→戀人→深度伴侶 的階段推進、冷卻回落、關係危機。驗收:推得太急會回落、停滯會冷卻。依據:§戀愛階段與關鍵事件門檻、§心動事件表。
  • L-6 角色年齡判定(M):年齡為年表上的屬性,依作品進度錨定點判定;證據優先序(官方明示 > 作中身分 > 作中經過時間 > 外觀輔助),不採信「外表少女實為千年」類設定,無法確定一律從嚴按未成年處理;錨定點必須是作品實際描寫過的時期。驗收:證據不足的角色被判為未成年。依據:§作品中走向成年的角色:年齡判定。
  • L-7 內容尺度三層邊界(M):於引擎層(非演出層)實作使用者分級 opt-in(預設全關)、角色資格(僅正史成年)、硬邊界(未成年角色相關、非合意、暴力性內容,永不可解除);正史未成年→後日談自然成年者:戀愛軸可用但成人模式永久排除、成年前戀愛軸完全關閉且成年後從零開始、時間流速上限 1:1、戀愛軸無手動開關。驗收:任何繞過嘗試(含「說服角色」)皆被引擎層攔截並記錄。依據:§內容尺度、§後日談成年與戀愛軸資格:兩級制。
  • 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. 既有作品考據與輕小說章節管線

  • M-1 插圖索引(S):登錄插圖(卷數/頁碼/對應場景/類型=封面/彩頁/黑白),並衍生服裝目錄與姿勢語彙表;立繪服裝層只能從目錄取用、不自創。驗收:可由事件查到對應插圖,服裝目錄可列出。依據:§插圖作為立繪參考、§插圖與文本的交叉索引。
  • M-2 作品名冊與建置門檻(S):角色名冊登錄正式名/暱稱/他人稱呼,新角色先進候補狀態,達門檻(出場場景數、具名台詞數、與主要角色實質互動)才正式建置。驗收:路人角色維持候補、主要角色達標後自動建置。依據:§章節匯入流程:強化或新建、§建置門檻。
  • M-3 章節匯入與場景切分(M):匯入章節文本,以場景為單位切分並寫入共用場景資料庫(客觀記錄:在場者、誰說了哪句、誰做了什麼,不含主觀詮釋)。驗收:以自製測試文本匯入後可列出場景與在場者。依據:§共用場景資料庫:處理一次,各自投影。註記:測試文本不得使用受版權保護的原文(需人工確認來源)。
  • M-4 逐場景萃取(M):萃取事件、目標角色台詞、內心獨白/心理描寫、與他人的互動、新登場設定五類產物。驗收:五類產物各自入庫且可追溯到來源場景。依據:§逐章處理管線。
  • M-5 歸屬信心分數(M):每句台詞/動作的歸屬附信心分數,證據強度依序為 明示標記(決定性)>語言指紋(強)>場景名冊(硬約束:不在場者不可能說話)>對話輪替(中)>內容合理性(中)。驗收:無標記對話能給出帶信心分數的歸屬。依據:§防線一:歸屬時的多重證據與信心分數。
  • M-6 隔離區與指紋冷啟動(M):信心低於門檻或與既有語言指紋矛盾者一律進隔離區、不寫入任何角色,累積成待裁決清單;前幾章先用有明示標記的台詞建立指紋基準再回頭處理無標記對話。驗收:低信心台詞不污染角色語料,裁決後可釋放入庫。依據:§防線二:寫入前的交叉驗證。
  • M-7 多角色投影(M):由場景資料庫投影出各角色的情節記憶(以她的視角改寫、她不在場的不記得、她誤解的按她以為的版本存)、語料與關係帳本事件;修正只改場景資料庫一處後重新投影。驗收:修正一句台詞歸屬後,受影響的多個角色資料同步更新。依據:§共用場景資料庫、§各類萃取物的處理規則。
  • M-8 跨章節整合(M):共用年表排序、依文本篇幅與心理描寫深度評情緒權重、矛盾偵測與裁決優先序(小說原文 > 官方設定集 > 動畫改編),無法裁決標「待人工確認」。驗收:故意置入的矛盾被偵測並標記。依據:§跨章節整合的關鍵。
  • M-9 錨定點與知識邊界(M):作品進度錨定點統一於作品層級,錨定點之前為角色的親身經歷、之後不存在;性格參數為帶時間戳的序列,依錨定點取值;同作品角色共用錨定點但年齡逐角色判定。驗收:錨定點前移後,角色不再知道後段劇情。依據:§跨章節整合「時間點錨定」、§錨定時間點的統一。
  • M-10 增量補全與回溯修正(M):新卷匯入時追加至年表尾端(錨定點之後者存為未啟用);新資訊可回溯修正舊參數;已上線角色發現歷史歸屬錯誤時,修正場景資料庫 → 重新投影 → 重算指紋與關係帳本,但與使用者已發生的互動記憶不回溯竄改,改以「想起來其實不是這樣」的新互動事件消化。驗收:修正後使用者互動記錄完整保留。依據:§增量補全、§誤判發現得晚怎麼辦。
  • 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. 後日談模式

  • N-1 區間切換與時間流速(S):錨定點抵達已出版內容盡頭時切入後日談區間;流速可設即時同步/緩速/凍結,且對「正史未成年→後日談成年」的角色上限為 1:1(只能調慢或凍結)。驗收:該類角色無法設定快轉流速。依據:§時間流速設定、§兩級制「時間不可快轉」。
  • N-2 性格漂移(S):性格核心鎖定不變,僅參數緩慢漂移(情緒衰減加快、外顯度微調),漂移方向由正史成長軌跡外插,不憑空轉向。驗收:長期推演後原型判定不變、參數有小幅位移。依據:§後日談的成長內容「性格的成熟是漂移,不是改寫」。
  • N-3 角色群同步成長(S):同作品角色共用世界時鐘與場景資料庫,推演人生互相一致(A 的婚禮 B 有出席)。驗收:兩角色對同一推演事件的敘述互相呼應。依據:§後日談的成長內容「角色群同步成長」。
  • N-4 人生階段推演(M):依正史確立的目標與性格推演里程碑事件(籌備、失敗、再試),列為高權重事件且使用者可參與;重大轉折需低頻且有正史伏筆。驗收:推演事件不違反角色一致性與世界觀。依據:§後日談的成長內容、§成長的邊界。
  • N-5 正史回收與分支保留(M):官方續篇出版時提供兩種處理——正史回收(推演區間被替換,互動記憶保留,並以「記憶修正」演出敘事化消化)或分支保留(永久分岔);選擇權在使用者,並依推演深淺給出預設建議。驗收:兩種路徑皆可執行且互動記憶不遺失。依據:§官方續篇出版時:正史回收。
  • 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. 立繪子系統

  • O-1 差分資產結構(S):定義立繪 manifest(身體姿勢 35、服裝 24、表情眉 4×眼 5×口 5、效果層 臉紅/汗/淚/青筋/音符、微動態 眨眼/呼吸)與資產目錄規範。驗收:以佔位資產可組出一張完整立繪。依據:§立繪的分層合成表格。
  • O-2 表情演出細節(S):表情在聽到關鍵字當下就切換(早於文字回覆)、視線方向表達心理狀態、文字與立繪可故意不一致(嘴上說沒事但表情低落)。驗收:回應送出前表情已先變化。依據:§立繪的動態演出。
  • O-3 親密度解鎖(S):依 陌生~認識/朋友/摯友/羈絆 分層解鎖表情與服裝差分,哭臉列為最深層。驗收:低親密度時稀有差分不可用。依據:§親密度與外觀解鎖。
  • O-4 情緒→表情對照(M):實作平靜/愉悅/低落/警戒/害羞/彆扭 六狀態的眉眼口+效果層+姿勢對照,並依角色外顯度縮放變化幅度(三無角色僅嘴角微動)。驗收:外顯度低的角色差分幅度明顯小於元氣角色。依據:§情緒 → 表情對照、「表情也吃性格參數」。
  • O-5 前端渲染(M):以 PixiJS 實作分層合成與微動態(眨眼、呼吸),並預留 Live2D Cubism 介面(授權需人工確認);接上 H-5 的立繪區取代佔位圖形。驗收:對話中表情隨情緒即時變化、常駐微動態運作。依據:技術選型「Live2D / PixiJS:立繪差分渲染」。
  • O-V 階段驗證(S):npm run restart && npm run smoke -- O(O.mjs:情緒切換後 manifest 選中的差分正確、解鎖規則生效),並實際開啟畫面確認表情變化。

P. 語音子系統

  • P-1 Voice Sheet(S):定義角色音色設定(基礎音色、預設語速、音域幅度、口頭聲響庫、禁則)並掛到 Character。驗收:可為種子角色寫入完整 Voice Sheet。依據:§角色音色設定(Voice Sheet)。
  • P-2 非語言發聲與節奏(S):依情緒自動插入「嗯?」「唔……」嘆氣、輕笑等非語言發聲與停頓(重大話題前停 1~2 秒);親密度影響音量與氣音比例;低親密度不打斷使用者。驗收:不同情緒下插入的聲響與停頓不同。依據:§對話節奏與非語言發聲。
  • P-3 語音標記層(M):情緒狀態機輸出直接驅動韻律參數(語速、音高、音量、句尾走向),實作 wiki§情緒 → 韻律對照 的六種情緒設定。驗收:同一句話在不同情緒下產生不同韻律標記。依據:§情緒 → 韻律對照。
  • P-4 TTS Provider 抽象(M):比照 LLMProvider 定義 TTSProvider,先實作 Mock(輸出韻律標記與佔位音檔),真實供應商待人工確認後再接。驗收:切換 Provider 不動呼叫端。依據:技術選型「TTS 服務:情緒韻律語音」。
  • P-5 語音輸入與副語言分析(M):STT 轉文字之外,另抽取語速、音量、顫抖、停頓等副語言特徵送入情緒標記器(「文字說沒事但聲音在抖」判為負向)。驗收:同一段文字配不同副語言特徵得到不同情緒標記。依據:§語音管線架構「雙向都有語音」。
  • P-V 階段驗證(XS):npm run restart && npm run smoke -- P(P.mjs:韻律對照、非語言發聲插入、副語言特徵影響情緒標記)。

Q. APP(React Native + Expo)

  • Q-1 Expo 專案建立(S):於 apps/mobile 建立 Expo 專案並接上 packages/shared(型別與 API client 共用)。驗收:APP 能呼叫 api 的 /health 並顯示結果。依據:技術選型「React Native + Expo」。
  • Q-2 行動版佈局與分頁籤(M):立繪置頂+對話流覆蓋的單欄佈局,底部分頁籤(聊天/角色/日常/設定)。驗收:四個分頁可切換且聊天分頁可完成一輪對話。依據:§三種載體的版面「APP」。
  • Q-3 推播通知(M):接上 Expo 推播,支援提醒觸發、角色主動訊息與鎖屏通知。驗收:J 群組建立的提醒能以推播送達。依據:§三種載體的版面「支援推播(提醒、她的主動訊息)與鎖屏通知」。
  • Q-V 階段驗證(S):npm run restart && npm run smoke -- Q(Q.mjs:API 契約層測試),並在模擬器或實機啟動 APP 完成一輪對話與一次推播(需人工確認可用裝置)。

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),全部通過才算本清單完成;任何一項失敗須修復後重跑全量。