feat: 完成 J 群組 — 委託子系統

Task 資料表與自然語句解析(單次提醒)、依原型的接案/觸發/逾期追擊演出、
委託與關係帳本/情緒的回饋迴路(含默契旗標)、主動關照(待辦偵測+作息交叉比對,
稀有度節流不重複台詞)、排程器可靠性(服務重啟後重新掃描 PENDING 任務掛回
JobQueue,逾期立即補觸發)。J.mjs 以 spawnSync 實際重啟服務驗證 J-7。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-08-13 14:35:16 +08:00
co-authored by Claude Sonnet 5
parent d82b834e47
commit 3503cc0be6
16 changed files with 1117 additions and 8 deletions
+18 -8
View File
@@ -265,14 +265,24 @@ flowchart TB
### 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:一分鐘後提醒必觸發、重啟後不遺失、演出符合原型、逾期追擊)。
- [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. 對話模式:群聊與角色自聊