Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cd371d985 | ||
|
|
127a59dafd | ||
|
|
a45a5f1fc3 | ||
|
|
5732925f64 | ||
|
|
021284b852 | ||
|
|
30719a2960 | ||
|
|
6ce7b2e800 | ||
|
|
080ac390d3 | ||
|
|
5da8b3a63e | ||
|
|
0e9401b891 | ||
|
|
0f3d16059c | ||
|
|
eccdcba45c | ||
|
|
c66799ee7c | ||
|
|
cd948f0026 | ||
|
|
6125d6e27f | ||
|
|
3503cc0be6 | ||
|
|
d82b834e47 | ||
|
|
9e5036f0a0 |
@@ -1,3 +1,14 @@
|
|||||||
# 複製為 .env 後依需要調整。
|
# 複製為 .env 後依需要調整。
|
||||||
# R-2 階段會改為 postgresql 連線字串(例如 postgresql://user:password@localhost:5432/kokorone)。
|
# R-2 階段會改為 postgresql 連線字串(例如 postgresql://user:password@localhost:5432/kokorone)。
|
||||||
DATABASE_URL="file:./prisma/dev.db"
|
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
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ coverage/
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
packages/db/generated/
|
packages/db/generated/
|
||||||
|
.claude/scheduled_tasks.lock
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"_comment": "kiro-cli has no verified native hook system (no 'hooks' field in kiro-cli agent config; no hook subcommand). This file documents the INTENDED wiring for when kiro-cli gains hook support, and doubles as a manual reference for the wrapper scripts in this directory. JSC_CLI=kiro is exported by each wrapper.",
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{ "hooks": [ { "type": "command", "command": "sh \"$PWD/.kiro/hooks/session-timer.sh\" start" } ] }
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{ "hooks": [ { "type": "command", "command": "sh \"$PWD/.kiro/hooks/ste100-guard.sh\"" } ] }
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{ "hooks": [ { "type": "command", "command": "sh \"$PWD/.kiro/hooks/session-timer.sh\" mark" } ] }
|
||||||
|
],
|
||||||
|
"SessionEnd": [
|
||||||
|
{ "hooks": [ { "type": "command", "command": "sh \"$PWD/.kiro/hooks/session-timer.sh\" mark" } ] }
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{ "matcher": "Skill", "hooks": [ { "type": "command", "command": "sh \"$PWD/.kiro/hooks/skill-usage.sh\"" } ] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# session-timer.sh — kiro-cli wrapper for jsc-hooks' session timer.
|
||||||
|
# See ste100-guard.sh in this directory for the kiro-cli hook-support assumption.
|
||||||
|
# Usage: session-timer.sh start|mark|report [sid]
|
||||||
|
export JSC_CLI=kiro
|
||||||
|
exec sh "/root/.claude/plugins/cache/jsc/jsc-hooks/0.0.2/hooks/session-timer.sh" "$@"
|
||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# skill-usage.sh — kiro-cli wrapper for jsc-hooks' skill usage logger.
|
||||||
|
# See ste100-guard.sh in this directory for the kiro-cli hook-support assumption.
|
||||||
|
# Usage: JSC_SKILL=<skill-name> skill-usage.sh
|
||||||
|
export JSC_CLI=kiro
|
||||||
|
exec sh "/root/.claude/plugins/cache/jsc/jsc-hooks/0.0.2/hooks/skill-usage.sh" "$@"
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# ste100-guard.sh — kiro-cli wrapper for jsc-hooks' STE100 guard.
|
||||||
|
#
|
||||||
|
# ASSUMPTION: kiro-cli (2.18.1) exposes no native "agent hook" / lifecycle-hook
|
||||||
|
# concept (checked `kiro-cli agent --help`, `kiro-cli agent create --help`, and
|
||||||
|
# the agent_config.json.example schema — no hooks field). This directory and
|
||||||
|
# script are a pragmatic manual fallback, not something kiro-cli invokes on
|
||||||
|
# its own. If kiro-cli later adds a hooks.json-style mechanism, point it at
|
||||||
|
# the scripts in this directory (they already set JSC_CLI=kiro).
|
||||||
|
export JSC_CLI=kiro
|
||||||
|
exec sh "/root/.claude/plugins/cache/jsc/jsc-hooks/0.0.2/hooks/ste100-guard.sh" "$@"
|
||||||
@@ -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 指令」,中間不需要引入額外的容器化工具鏈。
|
||||||
|
|||||||
@@ -10,13 +10,14 @@
|
|||||||
"start": "node dist/main.js"
|
"start": "node dist/main.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kokorone/db": "*",
|
"@kokorone/db": "workspace:*",
|
||||||
"@kokorone/shared": "*",
|
"@kokorone/shared": "workspace:*",
|
||||||
"@nestjs/common": "^11.1.29",
|
"@nestjs/common": "^11.1.29",
|
||||||
"@nestjs/core": "^11.1.29",
|
"@nestjs/core": "^11.1.29",
|
||||||
"@nestjs/event-emitter": "^3.1.0",
|
"@nestjs/event-emitter": "^3.1.0",
|
||||||
"@nestjs/platform-express": "^11.1.29",
|
"@nestjs/platform-express": "^11.1.29",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
"ioredis": "^6.0.0",
|
||||||
"node-cron": "^4.6.0",
|
"node-cron": "^4.6.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2"
|
"rxjs": "^7.8.2"
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ import { EmotionModule } from "./emotion/emotion.module.js";
|
|||||||
import { RelationshipModule } from "./relationship/relationship.module.js";
|
import { RelationshipModule } from "./relationship/relationship.module.js";
|
||||||
import { LlmModule } from "./llm/llm.module.js";
|
import { LlmModule } from "./llm/llm.module.js";
|
||||||
import { PersonalityModule } from "./personality/personality.module.js";
|
import { PersonalityModule } from "./personality/personality.module.js";
|
||||||
|
import { ChatModule } from "./chat/chat.module.js";
|
||||||
|
import { ScheduleModule } from "./schedule/schedule.module.js";
|
||||||
|
import { TaskModule } from "./task/task.module.js";
|
||||||
|
import { RoomModule } from "./room/room.module.js";
|
||||||
|
import { RomanceModule } from "./romance/romance.module.js";
|
||||||
|
import { CanonModule } from "./canon/canon.module.js";
|
||||||
|
import { EpilogueModule } from "./epilogue/epilogue.module.js";
|
||||||
|
import { TachieModule } from "./tachie/tachie.module.js";
|
||||||
|
import { VoiceModule } from "./voice/voice.module.js";
|
||||||
|
import { PushModule } from "./push/push.module.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -17,6 +27,16 @@ import { PersonalityModule } from "./personality/personality.module.js";
|
|||||||
RelationshipModule,
|
RelationshipModule,
|
||||||
LlmModule,
|
LlmModule,
|
||||||
PersonalityModule,
|
PersonalityModule,
|
||||||
|
ChatModule,
|
||||||
|
ScheduleModule,
|
||||||
|
TaskModule,
|
||||||
|
RoomModule,
|
||||||
|
RomanceModule,
|
||||||
|
CanonModule,
|
||||||
|
EpilogueModule,
|
||||||
|
TachieModule,
|
||||||
|
VoiceModule,
|
||||||
|
PushModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
|
|||||||
Vendored
+26
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { ProjectionService } from "./projection.service.js";
|
||||||
|
import { WorkRosterService } from "./work-roster.service.js";
|
||||||
|
|
||||||
|
// M-9 錨定點與知識邊界:錨定點之前是角色的親身經歷、之後不存在。場景匯入時只寫入共用場景資料庫,
|
||||||
|
// 只有 storyOrder <= 錨定點的場景會被投影進角色記憶——移動錨定點(無論前進或後退)都要讓「已投影」的
|
||||||
|
// 場景集合與「storyOrder <= 錨定點」的場景集合重新同步。
|
||||||
|
@Injectable()
|
||||||
|
export class AnchorService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly projection: ProjectionService,
|
||||||
|
private readonly roster: WorkRosterService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async setAnchor(workId: string, newStoryOrder: number): Promise<{ projected: number; unprojected: number }> {
|
||||||
|
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: workId } });
|
||||||
|
|
||||||
|
const toProject = await this.prisma.client.scene.findMany({
|
||||||
|
where: { workId, storyOrder: { lte: newStoryOrder }, projected: false },
|
||||||
|
include: { participants: true },
|
||||||
|
});
|
||||||
|
for (const scene of toProject) {
|
||||||
|
await this.projection.projectScene(scene.id);
|
||||||
|
for (const presence of scene.participants) {
|
||||||
|
await this.roster.checkAndPromote(presence.characterId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 錨定點後退:她「不再知道」超出新錨定點的後段劇情——移除這些場景先前投影出的所有角色資料。
|
||||||
|
const toUnproject = await this.prisma.client.scene.findMany({
|
||||||
|
where: { workId, storyOrder: { gt: newStoryOrder }, projected: true },
|
||||||
|
});
|
||||||
|
for (const scene of toUnproject) {
|
||||||
|
await this.prisma.client.episodicMemory.deleteMany({ where: { sourceSceneId: scene.id } });
|
||||||
|
await this.prisma.client.semanticMemory.deleteMany({ where: { sourceSceneId: scene.id } });
|
||||||
|
await this.prisma.client.personalityTraitSnapshot.deleteMany({ where: { sourceSceneId: scene.id } });
|
||||||
|
await this.prisma.client.characterRelationshipEventLog.deleteMany({ where: { sceneId: scene.id } });
|
||||||
|
await this.prisma.client.scene.update({ where: { id: scene.id }, data: { projected: false } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.client.work.update({ where: { id: work.id }, data: { anchorStoryOrder: newStoryOrder } });
|
||||||
|
return { projected: toProject.length, unprojected: toUnproject.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// M-9 依錨定點取用「當時的她」的性格參數:取 atStoryOrder <= 錨定點裡最新的一筆快照。
|
||||||
|
async effectivePersonalitySnapshot(characterId: string, atStoryOrder: number) {
|
||||||
|
return this.prisma.client.personalityTraitSnapshot.findFirst({
|
||||||
|
where: { characterId, atStoryOrder: { lte: atStoryOrder } },
|
||||||
|
orderBy: { atStoryOrder: "desc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { FingerprintService } from "./fingerprint.service.js";
|
||||||
|
import { FINGERPRINT_WEIGHT, TURN_TAKING_WEIGHT, CONFIRM_THRESHOLD, FINGERPRINT_CONTRADICTION_SCORE } from "./constants.js";
|
||||||
|
|
||||||
|
interface CandidateScore {
|
||||||
|
characterId: string;
|
||||||
|
score: number;
|
||||||
|
fingerprintScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// M-5/M-6 歸屬時的多重證據與信心分數,以及隔離區機制:
|
||||||
|
// 明示標記(決定性)> 語言指紋(強)> 場景名冊(硬約束,篩掉不在場者)> 對話輪替(中)> 內容合理性(中,本實作暫略)。
|
||||||
|
// 低信心一律隔離,不寫入任何角色——寧可缺這句語料,不可污染角色資料。
|
||||||
|
@Injectable()
|
||||||
|
export class AttributionService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly fingerprint: FingerprintService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// 冷啟動順序:先處理有明示標記的台詞(建立指紋基準),再回頭處理無標記對話。
|
||||||
|
async attributeScene(sceneId: string): Promise<void> {
|
||||||
|
const [scene, presences] = await Promise.all([
|
||||||
|
this.prisma.client.scene.findUniqueOrThrow({ where: { id: sceneId } }),
|
||||||
|
this.prisma.client.scenePresence.findMany({ where: { sceneId } }),
|
||||||
|
]);
|
||||||
|
const presentCharacterIds = presences.map((p) => p.characterId);
|
||||||
|
|
||||||
|
const lines = await this.prisma.client.sceneLine.findMany({
|
||||||
|
where: { sceneId, status: "PENDING" },
|
||||||
|
orderBy: { lineIndex: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const markerLines = lines.filter((line) => line.hasExplicitMarker);
|
||||||
|
const unmarkedLines = lines.filter((line) => !line.hasExplicitMarker);
|
||||||
|
|
||||||
|
for (const line of markerLines) {
|
||||||
|
await this.confirm(line.id, line.markerCharacterId!, 1, scene.id, line.lineType);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of unmarkedLines) {
|
||||||
|
if (line.lineType === "NARRATION") {
|
||||||
|
// 敘述/新登場設定沒有「說話者」的歸屬問題,直接視為已確認(不需要指紋比對)。
|
||||||
|
await this.confirm(line.id, null, 1, scene.id, line.lineType);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await this.attributeLine(line.id, sceneId, presentCharacterIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async attributeLine(lineId: string, sceneId: string, presentCharacterIds: string[]): Promise<void> {
|
||||||
|
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
|
||||||
|
|
||||||
|
if (presentCharacterIds.length === 0) {
|
||||||
|
await this.quarantine(lineId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousConfirmed = await this.prisma.client.sceneLine.findFirst({
|
||||||
|
where: { sceneId, lineIndex: { lt: line.lineIndex }, status: "CONFIRMED", speakerCharacterId: { not: null } },
|
||||||
|
orderBy: { lineIndex: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const candidates: CandidateScore[] = [];
|
||||||
|
for (const characterId of presentCharacterIds) {
|
||||||
|
const fingerprintScore = await this.fingerprint.matchScore(line.rawText, characterId);
|
||||||
|
// 對話輪替:兩人場景中,上一句已確認的發言者若不是這位候選人,給予輪替加成(一問一答的結構)。
|
||||||
|
const turnTakingScore =
|
||||||
|
presentCharacterIds.length === 2 && previousConfirmed && previousConfirmed.speakerCharacterId !== characterId
|
||||||
|
? 1
|
||||||
|
: 0.5;
|
||||||
|
const score = fingerprintScore * FINGERPRINT_WEIGHT + turnTakingScore * TURN_TAKING_WEIGHT;
|
||||||
|
candidates.push({ characterId, score, fingerprintScore });
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort((a, b) => b.score - a.score);
|
||||||
|
const best = candidates[0];
|
||||||
|
|
||||||
|
if (best.score < CONFIRM_THRESHOLD || best.fingerprintScore < FINGERPRINT_CONTRADICTION_SCORE) {
|
||||||
|
await this.quarantine(lineId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.confirm(lineId, best.characterId, best.score, sceneId, line.lineType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async confirm(
|
||||||
|
lineId: string,
|
||||||
|
speakerCharacterId: string | null,
|
||||||
|
confidence: number,
|
||||||
|
sceneId: string,
|
||||||
|
lineType: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.prisma.client.sceneLine.update({
|
||||||
|
where: { id: lineId },
|
||||||
|
data: { speakerCharacterId, confidence, status: "CONFIRMED" },
|
||||||
|
});
|
||||||
|
if (speakerCharacterId && lineType === "DIALOGUE") {
|
||||||
|
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
|
||||||
|
await this.prisma.client.languageCorpusEntry.create({
|
||||||
|
data: { characterId: speakerCharacterId, sceneId, text: line.rawText },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async quarantine(lineId: string): Promise<void> {
|
||||||
|
await this.prisma.client.sceneLine.update({ where: { id: lineId }, data: { status: "QUARANTINED", confidence: 0 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// M-6 人工裁決:解除隔離,直接指定歸屬並寫入場景資料庫(含語料)。
|
||||||
|
async adjudicate(lineId: string, speakerCharacterId: string): Promise<void> {
|
||||||
|
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
|
||||||
|
await this.confirm(lineId, speakerCharacterId, 1, line.sceneId, line.lineType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// M-6 指紋精煉:語料變多後,回頭重新嘗試隔離區裡的台詞,部分可望被釋放入庫。
|
||||||
|
async reattemptQuarantined(sceneId: string): Promise<number> {
|
||||||
|
const presences = await this.prisma.client.scenePresence.findMany({ where: { sceneId } });
|
||||||
|
const presentCharacterIds = presences.map((p) => p.characterId);
|
||||||
|
const quarantined = await this.prisma.client.sceneLine.findMany({
|
||||||
|
where: { sceneId, status: "QUARANTINED" },
|
||||||
|
orderBy: { lineIndex: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
let released = 0;
|
||||||
|
for (const line of quarantined) {
|
||||||
|
await this.prisma.client.sceneLine.update({ where: { id: line.id }, data: { status: "PENDING" } });
|
||||||
|
await this.attributeLine(line.id, sceneId, presentCharacterIds);
|
||||||
|
const updated = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: line.id } });
|
||||||
|
if (updated.status === "CONFIRMED") {
|
||||||
|
released += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return released;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post } from "@nestjs/common";
|
||||||
|
import type { IllustrationType } from "@kokorone/db";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { SceneImportService, type ImportSceneInput } from "./scene-import.service.js";
|
||||||
|
import { AttributionService } from "./attribution.service.js";
|
||||||
|
import { CorrectionService } from "./correction.service.js";
|
||||||
|
import { WorkRosterService } from "./work-roster.service.js";
|
||||||
|
import { IllustrationService } from "./illustration.service.js";
|
||||||
|
import { AnchorService } from "./anchor.service.js";
|
||||||
|
import { TimelineService } from "./timeline.service.js";
|
||||||
|
|
||||||
|
@Controller("canon")
|
||||||
|
export class CanonController {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly sceneImport: SceneImportService,
|
||||||
|
private readonly attribution: AttributionService,
|
||||||
|
private readonly correction: CorrectionService,
|
||||||
|
private readonly roster: WorkRosterService,
|
||||||
|
private readonly illustration: IllustrationService,
|
||||||
|
private readonly anchor: AnchorService,
|
||||||
|
private readonly timeline: TimelineService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post("scenes")
|
||||||
|
async importScene(@Body() body: ImportSceneInput) {
|
||||||
|
return this.sceneImport.importScene(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("scenes/:sceneId")
|
||||||
|
async getScene(@Param("sceneId") sceneId: string) {
|
||||||
|
return this.prisma.client.scene.findUniqueOrThrow({
|
||||||
|
where: { id: sceneId },
|
||||||
|
include: { participants: true, lines: true, facts: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("works/:workId/scenes")
|
||||||
|
async listScenes(@Param("workId") workId: string) {
|
||||||
|
return this.prisma.client.scene.findMany({ where: { workId }, orderBy: { storyOrder: "asc" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("scene-lines/:lineId/adjudicate")
|
||||||
|
async adjudicate(@Param("lineId") lineId: string, @Body() body: { speakerCharacterId: string }) {
|
||||||
|
await this.attribution.adjudicate(lineId, body.speakerCharacterId);
|
||||||
|
return this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("scenes/:sceneId/reattempt-quarantined")
|
||||||
|
async reattemptQuarantined(@Param("sceneId") sceneId: string) {
|
||||||
|
const released = await this.attribution.reattemptQuarantined(sceneId);
|
||||||
|
return { released };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("scene-lines/:lineId/correct")
|
||||||
|
async correctAttribution(@Param("lineId") lineId: string, @Body() body: { newSpeakerCharacterId: string; now?: string }) {
|
||||||
|
await this.correction.correctLineAttribution(lineId, body.newSpeakerCharacterId, body.now ? new Date(body.now) : undefined);
|
||||||
|
return this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("characters/:characterId/roster-progress")
|
||||||
|
async rosterProgress(@Param("characterId") characterId: string) {
|
||||||
|
return this.roster.getProgress(characterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("illustrations")
|
||||||
|
async registerIllustration(
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
workId: string;
|
||||||
|
volume: number;
|
||||||
|
page?: number;
|
||||||
|
type: IllustrationType;
|
||||||
|
sceneId?: string;
|
||||||
|
description: string;
|
||||||
|
costumeLabels?: { characterId: string; label: string }[];
|
||||||
|
poseLabels?: { characterId: string; label: string }[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.illustration.register(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("scenes/:sceneId/illustrations")
|
||||||
|
async illustrationsForScene(@Param("sceneId") sceneId: string) {
|
||||||
|
return this.illustration.findByScene(sceneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("characters/:characterId/costume-catalog")
|
||||||
|
async costumeCatalog(@Param("characterId") characterId: string) {
|
||||||
|
return this.illustration.listCostumeCatalog(characterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("characters/:characterId/pose-vocabulary")
|
||||||
|
async poseVocabulary(@Param("characterId") characterId: string) {
|
||||||
|
return this.illustration.listPoseVocabulary(characterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("works/:workId/anchor")
|
||||||
|
async setAnchor(@Param("workId") workId: string, @Body() body: { storyOrder: number }) {
|
||||||
|
return this.anchor.setAnchor(workId, body.storyOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("works/:workId/detect-contradictions")
|
||||||
|
async detectContradictions(@Param("workId") workId: string) {
|
||||||
|
const created = await this.timeline.detectContradictions(workId);
|
||||||
|
return { created };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("works/:workId/contradictions")
|
||||||
|
async listContradictions(@Param("workId") workId: string) {
|
||||||
|
return this.timeline.listContradictions(workId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||||
|
import { EmotionModule } from "../emotion/emotion.module.js";
|
||||||
|
import { FingerprintService } from "./fingerprint.service.js";
|
||||||
|
import { WorkRosterService } from "./work-roster.service.js";
|
||||||
|
import { IllustrationService } from "./illustration.service.js";
|
||||||
|
import { AttributionService } from "./attribution.service.js";
|
||||||
|
import { ProjectionService } from "./projection.service.js";
|
||||||
|
import { SceneImportService } from "./scene-import.service.js";
|
||||||
|
import { AnchorService } from "./anchor.service.js";
|
||||||
|
import { TimelineService } from "./timeline.service.js";
|
||||||
|
import { CorrectionService } from "./correction.service.js";
|
||||||
|
import { CanonController } from "./canon.controller.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, EmotionModule],
|
||||||
|
controllers: [CanonController],
|
||||||
|
providers: [
|
||||||
|
FingerprintService,
|
||||||
|
WorkRosterService,
|
||||||
|
IllustrationService,
|
||||||
|
AttributionService,
|
||||||
|
ProjectionService,
|
||||||
|
SceneImportService,
|
||||||
|
AnchorService,
|
||||||
|
TimelineService,
|
||||||
|
CorrectionService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
FingerprintService,
|
||||||
|
WorkRosterService,
|
||||||
|
IllustrationService,
|
||||||
|
AttributionService,
|
||||||
|
ProjectionService,
|
||||||
|
SceneImportService,
|
||||||
|
AnchorService,
|
||||||
|
TimelineService,
|
||||||
|
CorrectionService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class CanonModule {}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// M-2 建置門檻:新角色候補累積到這些條件才正式建置(路人只出現一次不值得建置)。
|
||||||
|
export const BUILD_THRESHOLD_SCENES = 3; // 出場場景數
|
||||||
|
export const BUILD_THRESHOLD_NAMED_LINES = 5; // 具名(已 CONFIRMED)台詞數
|
||||||
|
export const BUILD_THRESHOLD_INTERACTIONS = 1; // 與其他角色的實質互動(關係帳本事件)數
|
||||||
|
|
||||||
|
// M-5 歸屬信心分數:多重證據合成的權重與門檻。
|
||||||
|
export const FINGERPRINT_WEIGHT = 0.6;
|
||||||
|
export const TURN_TAKING_WEIGHT = 0.4;
|
||||||
|
export const CONFIRM_THRESHOLD = 0.55;
|
||||||
|
// M-6 指紋回檢:候選角色的指紋分數低於此值,即使總分達門檻仍視為與既有指紋矛盾,隔離不寫入。
|
||||||
|
export const FINGERPRINT_CONTRADICTION_SCORE = 0.15;
|
||||||
|
|
||||||
|
// M-5/M-6 語言指紋:學習自 LanguageCorpusEntry 的特徵詞頻率統計(人稱/語尾/口癖),非原型模板。
|
||||||
|
export const SIGNAL_TOKENS = ["呢~", "本大爺", "的說", "喵"];
|
||||||
|
// 語料達此則數以上,才認定「指紋已建立」(樣本太少時中性看待,不視為矛盾)。
|
||||||
|
export const FINGERPRINT_MIN_SAMPLES = 3;
|
||||||
|
|
||||||
|
// M-8 矛盾裁決優先序(數值越小優先權越高):小說原文 > 官方設定集 > 動畫改編。
|
||||||
|
export const SOURCE_PRIORITY: Record<string, number> = {
|
||||||
|
NOVEL: 1,
|
||||||
|
OFFICIAL_SETTEI: 2,
|
||||||
|
ANIME_ADAPTATION: 3,
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { ProjectionService } from "./projection.service.js";
|
||||||
|
|
||||||
|
// M-7/M-10 歸屬修正與重新投影:修正場景資料庫只改一處,受影響角色的資料同步更新;
|
||||||
|
// 已上線角色發現歷史歸屬錯誤時,使用者互動記憶(source=INTERACTION)從不被觸碰——
|
||||||
|
// 只有由這個場景投影而來的資料(sourceSceneId 指到它)會被清掉重建。發現得晚的錯誤額外記一筆
|
||||||
|
// 「想起來其實不是這樣」的新事件,而不是假裝沒發生過。
|
||||||
|
@Injectable()
|
||||||
|
export class CorrectionService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly projection: ProjectionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async correctLineAttribution(lineId: string, newSpeakerCharacterId: string, now: Date = new Date()): Promise<void> {
|
||||||
|
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
|
||||||
|
const oldSpeakerCharacterId = line.speakerCharacterId;
|
||||||
|
if (oldSpeakerCharacterId === newSpeakerCharacterId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scene = await this.prisma.client.scene.findUniqueOrThrow({ where: { id: line.sceneId } });
|
||||||
|
const wasAlreadyProjected = scene.projected;
|
||||||
|
|
||||||
|
if (line.lineType === "DIALOGUE" && oldSpeakerCharacterId) {
|
||||||
|
await this.prisma.client.languageCorpusEntry.deleteMany({
|
||||||
|
where: { characterId: oldSpeakerCharacterId, sceneId: scene.id, text: line.rawText },
|
||||||
|
});
|
||||||
|
await this.prisma.client.languageCorpusEntry.create({
|
||||||
|
data: { characterId: newSpeakerCharacterId, sceneId: scene.id, text: line.rawText },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.client.sceneLine.update({
|
||||||
|
where: { id: lineId },
|
||||||
|
data: { speakerCharacterId: newSpeakerCharacterId, status: "CONFIRMED", confidence: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wasAlreadyProjected) {
|
||||||
|
// 這場戲還沒被投影過(超前於錨定點,或當初就隔離未寫入),修正場景資料庫即可,沒有已投影的舊資料要清。
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.projection.reprojectScene(scene.id);
|
||||||
|
|
||||||
|
if (oldSpeakerCharacterId) {
|
||||||
|
await this.recordRealization(
|
||||||
|
oldSpeakerCharacterId,
|
||||||
|
`想起來,那句話其實不是我說的——是我記錯了。`,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.recordRealization(newSpeakerCharacterId, `原來那句話其實是我說的,之前記混了。`, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordRealization(characterId: string, content: string, now: Date): Promise<void> {
|
||||||
|
await this.prisma.client.episodicMemory.create({
|
||||||
|
data: {
|
||||||
|
characterId,
|
||||||
|
content,
|
||||||
|
occurredAt: now,
|
||||||
|
emotionTag: "CALM",
|
||||||
|
emotionIntensity: 0.3,
|
||||||
|
source: "CORRECTION",
|
||||||
|
weight: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { SIGNAL_TOKENS, FINGERPRINT_MIN_SAMPLES } from "./constants.js";
|
||||||
|
|
||||||
|
export type Fingerprint = Record<string, number>; // token -> 出現比例 0~1
|
||||||
|
|
||||||
|
function clamp01(value: number): number {
|
||||||
|
return Math.max(0, Math.min(1, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// M-5/M-6 語言指紋:從已確認歸屬的台詞語料統計特徵詞頻率,供歸屬判斷與矛盾回檢使用。
|
||||||
|
// 前幾章的指紋樣本很少時,統計不具代表性——樣本量門檻由 matchScore 的呼叫端(AttributionService)處理。
|
||||||
|
@Injectable()
|
||||||
|
export class FingerprintService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async computeFingerprint(characterId: string): Promise<{ fingerprint: Fingerprint; sampleCount: number }> {
|
||||||
|
const entries = await this.prisma.client.languageCorpusEntry.findMany({ where: { characterId } });
|
||||||
|
const fingerprint: Fingerprint = {};
|
||||||
|
for (const token of SIGNAL_TOKENS) {
|
||||||
|
const hits = entries.filter((entry) => entry.text.includes(token)).length;
|
||||||
|
fingerprint[token] = entries.length > 0 ? hits / entries.length : 0;
|
||||||
|
}
|
||||||
|
return { fingerprint, sampleCount: entries.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分數解讀:0.5 為中性(文本沒有任何特徵詞,或指紋樣本不足以判斷);
|
||||||
|
// 命中角色慣用的特徵詞 → 加分;命中角色從不使用的特徵詞(指紋矛盾) → 顯著扣分。
|
||||||
|
async matchScore(text: string, characterId: string): Promise<number> {
|
||||||
|
const { fingerprint, sampleCount } = await this.computeFingerprint(characterId);
|
||||||
|
const hitTokens = SIGNAL_TOKENS.filter((token) => text.includes(token));
|
||||||
|
if (hitTokens.length === 0) {
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
if (sampleCount < FINGERPRINT_MIN_SAMPLES) {
|
||||||
|
return 0.5; // 指紋尚未建立,中性看待,不因樣本不足誤判矛盾。
|
||||||
|
}
|
||||||
|
let score = 0.5;
|
||||||
|
for (const token of hitTokens) {
|
||||||
|
const freq = fingerprint[token];
|
||||||
|
if (freq > 0.3) {
|
||||||
|
score += 0.4;
|
||||||
|
} else if (freq > 0) {
|
||||||
|
score += 0.1;
|
||||||
|
} else {
|
||||||
|
score -= 0.4; // 明顯矛盾:這個角色的既有語料從未出現過這個特徵詞。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clamp01(score);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { IllustrationType } from "@kokorone/db";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
|
||||||
|
export interface RegisterIllustrationInput {
|
||||||
|
workId: string;
|
||||||
|
volume: number;
|
||||||
|
page?: number;
|
||||||
|
type: IllustrationType;
|
||||||
|
sceneId?: string;
|
||||||
|
description: string;
|
||||||
|
costumeLabels?: { characterId: string; label: string }[];
|
||||||
|
poseLabels?: { characterId: string; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// M-1 插圖索引:不儲存受版權保護的原圖,只登錄畫面描述;同時衍生服裝目錄與姿勢語彙表
|
||||||
|
// ——立繪服裝層之後只能從這個目錄取用,不可自創(見 O 群組立繪子系統會消費這裡的資料)。
|
||||||
|
@Injectable()
|
||||||
|
export class IllustrationService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async register(input: RegisterIllustrationInput) {
|
||||||
|
const illustration = await this.prisma.client.illustration.create({
|
||||||
|
data: {
|
||||||
|
workId: input.workId,
|
||||||
|
volume: input.volume,
|
||||||
|
page: input.page,
|
||||||
|
type: input.type,
|
||||||
|
sceneId: input.sceneId,
|
||||||
|
description: input.description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const costume of input.costumeLabels ?? []) {
|
||||||
|
await this.prisma.client.costumeCatalogEntry.create({
|
||||||
|
data: { characterId: costume.characterId, illustrationId: illustration.id, label: costume.label },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const pose of input.poseLabels ?? []) {
|
||||||
|
await this.prisma.client.poseVocabularyEntry.create({
|
||||||
|
data: { characterId: pose.characterId, illustrationId: illustration.id, label: pose.label },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return illustration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// M-1 驗收:可由事件(場景)查到對應插圖。
|
||||||
|
async findByScene(sceneId: string) {
|
||||||
|
return this.prisma.client.illustration.findMany({ where: { sceneId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCostumeCatalog(characterId: string) {
|
||||||
|
return this.prisma.client.costumeCatalogEntry.findMany({ where: { characterId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPoseVocabulary(characterId: string) {
|
||||||
|
return this.prisma.client.poseVocabularyEntry.findMany({ where: { characterId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||||
|
import { HIGH_EMOTION_THRESHOLD } from "../memory/constants.js";
|
||||||
|
|
||||||
|
const CANON_EPOCH_MS = new Date("2000-01-01T00:00:00.000Z").getTime();
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// 場景資料庫只有「年表排序鍵」(storyOrder),沒有真實日期;投影成 EpisodicMemory 時需要一個 DateTime,
|
||||||
|
// 用固定虛擬曆元 + storyOrder 天數換算即可——只要求相對順序正確,不代表任何真實日期。
|
||||||
|
function storyOrderToDate(storyOrder: number): Date {
|
||||||
|
return new Date(CANON_EPOCH_MS + storyOrder * DAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// M-4/M-7 多角色投影:共用場景資料庫只處理一次,各角色資料是投影出來的——修正場景資料庫一處,
|
||||||
|
// 重新投影後所有受影響角色同步更新。M-9 知識邊界:只有 storyOrder <= 錨定點的場景才會被投影。
|
||||||
|
@Injectable()
|
||||||
|
export class ProjectionService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly tagger: RuleBasedEmotionTagger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async projectScene(sceneId: string): Promise<void> {
|
||||||
|
const scene = await this.prisma.client.scene.findUniqueOrThrow({
|
||||||
|
where: { id: sceneId },
|
||||||
|
include: { participants: true, lines: true, interactions: true },
|
||||||
|
});
|
||||||
|
const presentCharacterIds = scene.participants.map((p) => p.characterId);
|
||||||
|
const occurredAt = storyOrderToDate(scene.storyOrder);
|
||||||
|
|
||||||
|
// 1) 事件 → 情節記憶:她不在場的不記得——只有在場角色才會拿到這場戲的情節記憶。
|
||||||
|
const signal = this.tagger.tag({ text: scene.summary });
|
||||||
|
const hasMonologue = scene.lines.some((line) => line.lineType === "MONOLOGUE" && line.status === "CONFIRMED");
|
||||||
|
const isHighEmotion = signal.intensity >= HIGH_EMOTION_THRESHOLD;
|
||||||
|
// M-8 情緒權重評分:文本篇幅與心理描寫深度是天然的情緒權重指標。
|
||||||
|
const weight = (isHighEmotion ? 1.5 : 0.8) * (hasMonologue ? 1.4 : 1) * (1 + Math.min(1, scene.rawText.length / 500));
|
||||||
|
for (const characterId of presentCharacterIds) {
|
||||||
|
await this.prisma.client.episodicMemory.create({
|
||||||
|
data: {
|
||||||
|
characterId,
|
||||||
|
content: scene.summary,
|
||||||
|
occurredAt,
|
||||||
|
emotionTag: signal.tag,
|
||||||
|
emotionIntensity: signal.intensity || 0.3,
|
||||||
|
source: "SOURCE_EXTRACTION",
|
||||||
|
weight,
|
||||||
|
sourceSceneId: scene.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 台詞 → 語言風格語料:已由 AttributionService 在確認歸屬的當下寫入 LanguageCorpusEntry,這裡不重複處理。
|
||||||
|
|
||||||
|
// 3) 內心獨白/心理描寫 → 性格參數證據。
|
||||||
|
const monologueLines = scene.lines.filter(
|
||||||
|
(line) => line.lineType === "MONOLOGUE" && line.status === "CONFIRMED" && line.speakerCharacterId,
|
||||||
|
);
|
||||||
|
for (const line of monologueLines) {
|
||||||
|
await this.prisma.client.personalityTraitSnapshot.create({
|
||||||
|
data: {
|
||||||
|
characterId: line.speakerCharacterId!,
|
||||||
|
sourceSceneId: scene.id,
|
||||||
|
atStoryOrder: scene.storyOrder,
|
||||||
|
note: line.rawText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 與他人的互動 → 關係帳本(角色對角色):中立記錄在此刻才真正套用到雙方視角。
|
||||||
|
for (const interaction of scene.interactions) {
|
||||||
|
await this.prisma.client.characterRelationshipEventLog.create({
|
||||||
|
data: {
|
||||||
|
characterId: interaction.characterId,
|
||||||
|
otherCharacterId: interaction.otherCharacterId,
|
||||||
|
sceneId: scene.id,
|
||||||
|
delta: interaction.delta,
|
||||||
|
description: interaction.description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) 新登場設定 → 語意記憶:NARRATION 為場景中立敘述,在場角色都「知道」這件事。
|
||||||
|
const narrationLines = scene.lines.filter((line) => line.lineType === "NARRATION" && line.status === "CONFIRMED");
|
||||||
|
for (const line of narrationLines) {
|
||||||
|
for (const characterId of presentCharacterIds) {
|
||||||
|
await this.prisma.client.semanticMemory.create({
|
||||||
|
data: { characterId, fact: line.rawText, about: "世界觀", sourceSceneId: scene.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.client.scene.update({ where: { id: scene.id }, data: { projected: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// M-7 重新投影:修正場景資料庫後,先移除這個場景先前投影出的所有資料,再重新投影一次。
|
||||||
|
// 只清「由這個場景投影而來」的紀錄(sourceSceneId 指到它)——使用者互動記憶從不設這個欄位,永遠不受影響。
|
||||||
|
async reprojectScene(sceneId: string): Promise<void> {
|
||||||
|
await this.prisma.client.episodicMemory.deleteMany({ where: { sourceSceneId: sceneId } });
|
||||||
|
await this.prisma.client.semanticMemory.deleteMany({ where: { sourceSceneId: sceneId } });
|
||||||
|
await this.prisma.client.personalityTraitSnapshot.deleteMany({ where: { sourceSceneId: sceneId } });
|
||||||
|
await this.prisma.client.characterRelationshipEventLog.deleteMany({ where: { sceneId } });
|
||||||
|
await this.projectScene(sceneId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { SceneLineType } from "@kokorone/db";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { AttributionService } from "./attribution.service.js";
|
||||||
|
import { ProjectionService } from "./projection.service.js";
|
||||||
|
import { WorkRosterService } from "./work-roster.service.js";
|
||||||
|
|
||||||
|
export interface ImportLineInput {
|
||||||
|
lineType: SceneLineType;
|
||||||
|
rawText: string;
|
||||||
|
hasExplicitMarker?: boolean;
|
||||||
|
markerCharacterId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportInteractionInput {
|
||||||
|
characterId: string;
|
||||||
|
otherCharacterId: string;
|
||||||
|
delta: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportFactInput {
|
||||||
|
eventLabel: string;
|
||||||
|
factKey: string;
|
||||||
|
factValue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportSceneInput {
|
||||||
|
workId: string;
|
||||||
|
volume: number;
|
||||||
|
chapterLabel: string;
|
||||||
|
storyOrder: number;
|
||||||
|
sourceType?: string;
|
||||||
|
summary: string;
|
||||||
|
rawText: string;
|
||||||
|
participantCharacterIds: string[];
|
||||||
|
lines: ImportLineInput[];
|
||||||
|
interactions?: ImportInteractionInput[];
|
||||||
|
facts?: ImportFactInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// M-3 章節匯入與場景切分:以場景為單位寫入共用場景資料庫(角色中立的客觀記錄)。
|
||||||
|
// 場景本身的切分(章節文本 → 一個個場景)由呼叫端(人工或前置流程)完成後以結構化輸入送進來——
|
||||||
|
// 這裡不做自由文本的自動場景邊界偵測,理由同 F-7 MockProvider:對應的真實 NLP 能力屬於之後才會補的範疇。
|
||||||
|
@Injectable()
|
||||||
|
export class SceneImportService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly attribution: AttributionService,
|
||||||
|
private readonly projection: ProjectionService,
|
||||||
|
private readonly roster: WorkRosterService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async importScene(input: ImportSceneInput) {
|
||||||
|
const scene = await this.prisma.client.scene.create({
|
||||||
|
data: {
|
||||||
|
workId: input.workId,
|
||||||
|
volume: input.volume,
|
||||||
|
chapterLabel: input.chapterLabel,
|
||||||
|
storyOrder: input.storyOrder,
|
||||||
|
sourceType: input.sourceType ?? "NOVEL",
|
||||||
|
summary: input.summary,
|
||||||
|
rawText: input.rawText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const characterId of input.participantCharacterIds) {
|
||||||
|
await this.prisma.client.scenePresence.create({ data: { sceneId: scene.id, characterId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [lineIndex, line] of input.lines.entries()) {
|
||||||
|
await this.prisma.client.sceneLine.create({
|
||||||
|
data: {
|
||||||
|
sceneId: scene.id,
|
||||||
|
lineIndex,
|
||||||
|
lineType: line.lineType,
|
||||||
|
rawText: line.rawText,
|
||||||
|
hasExplicitMarker: line.hasExplicitMarker ?? false,
|
||||||
|
markerCharacterId: line.markerCharacterId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const interaction of input.interactions ?? []) {
|
||||||
|
await this.prisma.client.sceneInteraction.create({
|
||||||
|
data: {
|
||||||
|
sceneId: scene.id,
|
||||||
|
characterId: interaction.characterId,
|
||||||
|
otherCharacterId: interaction.otherCharacterId,
|
||||||
|
delta: interaction.delta,
|
||||||
|
description: interaction.description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fact of input.facts ?? []) {
|
||||||
|
await this.prisma.client.sceneFact.create({
|
||||||
|
data: { sceneId: scene.id, eventLabel: fact.eventLabel, factKey: fact.factKey, factValue: fact.factValue },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.attribution.attributeScene(scene.id);
|
||||||
|
|
||||||
|
// M-9 知識邊界:只有在錨定點之內的場景才立刻投影;超前的場景先留在場景資料庫,等錨定點推進時再載入。
|
||||||
|
// 建置門檻的「實質互動」次數只在投影後才算數(互動要先套用到關係帳本才算數),因此門檻檢查必須排在投影之後。
|
||||||
|
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: input.workId } });
|
||||||
|
if (scene.storyOrder <= work.anchorStoryOrder) {
|
||||||
|
await this.projection.projectScene(scene.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const characterId of input.participantCharacterIds) {
|
||||||
|
await this.roster.checkAndPromote(characterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.client.scene.findUniqueOrThrow({ where: { id: scene.id }, include: { participants: true, lines: true } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { SOURCE_PRIORITY } from "./constants.js";
|
||||||
|
|
||||||
|
// M-8 跨章節整合:共用年表排序(Scene.storyOrder 本身即年表鍵,不需要另外的服務排序)、
|
||||||
|
// 矛盾偵測與裁決優先序(小說原文 > 官方設定集 > 動畫改編),無法裁決者標記待人工確認。
|
||||||
|
@Injectable()
|
||||||
|
export class TimelineService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// 掃描同一事件標籤下、同一事實鍵卻不同事實值的場景——這是故意置入矛盾最容易驗證的形式。
|
||||||
|
async detectContradictions(workId: string): Promise<number> {
|
||||||
|
const facts = await this.prisma.client.sceneFact.findMany({
|
||||||
|
where: { scene: { workId } },
|
||||||
|
include: { scene: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = new Map<string, typeof facts>();
|
||||||
|
for (const fact of facts) {
|
||||||
|
const key = `${fact.eventLabel}::${fact.factKey}`;
|
||||||
|
const group = groups.get(key) ?? [];
|
||||||
|
group.push(fact);
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
for (const [key, group] of groups) {
|
||||||
|
const distinctValues = new Set(group.map((fact) => fact.factValue));
|
||||||
|
if (distinctValues.size <= 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [eventLabel, factKey] = key.split("::");
|
||||||
|
const existing = await this.prisma.client.sceneContradiction.findFirst({ where: { workId, eventLabel, factKey } });
|
||||||
|
if (existing) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...group].sort(
|
||||||
|
(a, b) => (SOURCE_PRIORITY[a.scene.sourceType] ?? 99) - (SOURCE_PRIORITY[b.scene.sourceType] ?? 99),
|
||||||
|
);
|
||||||
|
const best = sorted[0];
|
||||||
|
const runnerUpDifferentValue = sorted.find((fact) => fact.factValue !== best.factValue);
|
||||||
|
const canAutoResolve =
|
||||||
|
runnerUpDifferentValue !== undefined &&
|
||||||
|
(SOURCE_PRIORITY[best.scene.sourceType] ?? 99) < (SOURCE_PRIORITY[runnerUpDifferentValue.scene.sourceType] ?? 99);
|
||||||
|
|
||||||
|
await this.prisma.client.sceneContradiction.create({
|
||||||
|
data: {
|
||||||
|
workId,
|
||||||
|
eventLabel,
|
||||||
|
factKey,
|
||||||
|
description: `「${eventLabel}」的「${factKey}」出現不同描述:${[...distinctValues].join(" vs ")}`,
|
||||||
|
status: canAutoResolve ? "RESOLVED" : "PENDING_HUMAN_REVIEW",
|
||||||
|
resolution: canAutoResolve
|
||||||
|
? `依裁決優先序(小說原文 > 官方設定集 > 動畫改編)採用「${best.factValue}」(來源:${best.scene.sourceType})`
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created += 1;
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listContradictions(workId: string) {
|
||||||
|
return this.prisma.client.sceneContradiction.findMany({ where: { workId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { BUILD_THRESHOLD_SCENES, BUILD_THRESHOLD_NAMED_LINES, BUILD_THRESHOLD_INTERACTIONS } from "./constants.js";
|
||||||
|
|
||||||
|
export interface RosterProgress {
|
||||||
|
characterId: string;
|
||||||
|
sceneCount: number;
|
||||||
|
namedLineCount: number;
|
||||||
|
interactionCount: number;
|
||||||
|
meetsThreshold: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// M-2 作品名冊與建置門檻:新角色先進候補狀態,資料照常在場景資料庫累積,達門檻才正式建置。
|
||||||
|
// 累積量一律即時查詢(場景/台詞/互動紀錄本身就是唯一真相),不維護額外的計數快取,避免同步漂移。
|
||||||
|
@Injectable()
|
||||||
|
export class WorkRosterService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getProgress(characterId: string): Promise<RosterProgress> {
|
||||||
|
const [sceneCount, namedLineCount, interactionCount] = await Promise.all([
|
||||||
|
this.prisma.client.scenePresence.count({ where: { characterId } }),
|
||||||
|
this.prisma.client.sceneLine.count({ where: { speakerCharacterId: characterId, status: "CONFIRMED" } }),
|
||||||
|
this.prisma.client.characterRelationshipEventLog.count({ where: { characterId } }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
characterId,
|
||||||
|
sceneCount,
|
||||||
|
namedLineCount,
|
||||||
|
interactionCount,
|
||||||
|
meetsThreshold:
|
||||||
|
sceneCount >= BUILD_THRESHOLD_SCENES &&
|
||||||
|
namedLineCount >= BUILD_THRESHOLD_NAMED_LINES &&
|
||||||
|
interactionCount >= BUILD_THRESHOLD_INTERACTIONS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匯入場景後對每個候補角色呼叫:達門檻就自動升級為正式建置,之後的匯入走「強化路徑」(不需要特別的程式碼分支
|
||||||
|
// ——強化與候補累積本來就是同一組寫入邏輯,差別只在 buildStatus 這個查詢結果)。
|
||||||
|
async checkAndPromote(characterId: string): Promise<boolean> {
|
||||||
|
const character = await this.prisma.client.character.findUnique({ where: { id: characterId } });
|
||||||
|
if (!character || character.buildStatus === "BUILT") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const progress = await this.getProgress(characterId);
|
||||||
|
if (!progress.meetsThreshold) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await this.prisma.client.character.update({ where: { id: characterId }, data: { buildStatus: "BUILT" } });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Body, Controller, Param, Post } from "@nestjs/common";
|
||||||
|
import { DialogueService } from "../llm/dialogue.service.js";
|
||||||
|
import { MemoryConsolidationService } from "../memory/memory-consolidation.service.js";
|
||||||
|
|
||||||
|
interface ChatMessageBody {
|
||||||
|
userId: string;
|
||||||
|
sessionId: string;
|
||||||
|
text: string;
|
||||||
|
seed?: number;
|
||||||
|
now?: string; // 測試用:模擬「現在」時間,供 I 群組作息相關行為驗證
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EndSessionBody {
|
||||||
|
characterId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// H-1 對話 API:POST /chat/:characterId 走完整管線,POST /session/:sessionId/end 觸發睡眠固化。
|
||||||
|
// 這是本清單第一個對外的正式使用者端點,先前 F 群組的 /dialogue/* 僅供工程內部驗證。
|
||||||
|
@Controller()
|
||||||
|
export class ChatController {
|
||||||
|
constructor(
|
||||||
|
private readonly dialogue: DialogueService,
|
||||||
|
private readonly consolidation: MemoryConsolidationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post("chat/:characterId")
|
||||||
|
async chat(@Param("characterId") characterId: string, @Body() body: ChatMessageBody) {
|
||||||
|
return this.dialogue.handleMessage(characterId, body.userId, body.sessionId, body.text, {
|
||||||
|
seed: body.seed,
|
||||||
|
now: body.now ? new Date(body.now) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("session/:sessionId/end")
|
||||||
|
async endSession(@Param("sessionId") sessionId: string, @Body() body: EndSessionBody) {
|
||||||
|
await this.consolidation.consolidate(body.characterId, sessionId);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { LlmModule } from "../llm/llm.module.js";
|
||||||
|
import { MemoryModule } from "../memory/memory.module.js";
|
||||||
|
import { ChatController } from "./chat.controller.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [LlmModule, MemoryModule],
|
||||||
|
controllers: [ChatController],
|
||||||
|
})
|
||||||
|
export class ChatModule {}
|
||||||
@@ -5,6 +5,9 @@ import { RuleBasedEmotionTagger } from "./emotion-tagger.js";
|
|||||||
import { nextEmotionState, type TransitionContext } from "./emotion-state-machine.js";
|
import { nextEmotionState, type TransitionContext } from "./emotion-state-machine.js";
|
||||||
import { toResponseStyle, type ResponseStyle } from "./response-style.js";
|
import { toResponseStyle, type ResponseStyle } from "./response-style.js";
|
||||||
import { getArchetypeParams } from "../personality/archetype-params.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 群組)覆寫。
|
// D-2 時間衰減:預設半衰期 2 小時,實際速度由角色參數(G 群組)覆寫。
|
||||||
const DEFAULT_HALF_LIFE_MS = 2 * 60 * 60 * 1000;
|
const DEFAULT_HALF_LIFE_MS = 2 * 60 * 60 * 1000;
|
||||||
@@ -56,6 +59,11 @@ export interface ProcessInputOptions extends TransitionContext {
|
|||||||
triggerThreshold?: number;
|
triggerThreshold?: number;
|
||||||
halfLifeMs?: number;
|
halfLifeMs?: number;
|
||||||
now?: Date;
|
now?: Date;
|
||||||
|
// I-5:本輪對話沒有明確情緒訊號時,套用作息驅動的基線情緒(不覆蓋文字本身觸發的訊號)。
|
||||||
|
baselineSignal?: { tag: EmotionTag; intensity: number };
|
||||||
|
// P-5:語音輸入的副語言特徵(語速/音量/顫抖/停頓)分析出的訊號——文字說「沒事」但聲音在抖,
|
||||||
|
// 優先於作息基線(副語言是這一輪輸入本身的訊號,比時段基線更直接),但仍不覆蓋文字本身觸發的訊號。
|
||||||
|
paralinguisticSignal?: { tag: EmotionTag; intensity: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDomain(characterId: string, dims: Dimensions, updatedAt: Date): EmotionState {
|
function toDomain(characterId: string, dims: Dimensions, updatedAt: Date): EmotionState {
|
||||||
@@ -105,7 +113,12 @@ export class EmotionService {
|
|||||||
|
|
||||||
const rawSignal = this.tagger.tag({ text, triggerThreshold });
|
const rawSignal = this.tagger.tag({ text, triggerThreshold });
|
||||||
// 情緒觸發閾值除了縮放強度,也要能讓「難觸發」原型對弱刺激完全不觸發(而不只是觸發得比較小力)。
|
// 情緒觸發閾值除了縮放強度,也要能讓「難觸發」原型對弱刺激完全不觸發(而不只是觸發得比較小力)。
|
||||||
const signal = rawSignal.intensity < MIN_TRIGGER_INTENSITY ? { tag: "CALM" as const, intensity: 0 } : rawSignal;
|
let signal = rawSignal.intensity < MIN_TRIGGER_INTENSITY ? { tag: "CALM" as const, intensity: 0 } : rawSignal;
|
||||||
|
if (signal.tag === "CALM" && options.paralinguisticSignal) {
|
||||||
|
signal = options.paralinguisticSignal;
|
||||||
|
} else if (signal.tag === "CALM" && options.baselineSignal) {
|
||||||
|
signal = options.baselineSignal;
|
||||||
|
}
|
||||||
const next = nextEmotionState(currentDominant, signal, options);
|
const next = nextEmotionState(currentDominant, signal, options);
|
||||||
|
|
||||||
let updated: Dimensions;
|
let updated: Dimensions;
|
||||||
@@ -123,11 +136,45 @@ export class EmotionService {
|
|||||||
return { state: toDomain(characterId, updated, now), style: toResponseStyle(next) };
|
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(
|
private async decayedDimensions(
|
||||||
characterId: string,
|
characterId: string,
|
||||||
now: Date,
|
now: Date,
|
||||||
halfLifeMs = DEFAULT_HALF_LIFE_MS,
|
halfLifeMs = DEFAULT_HALF_LIFE_MS,
|
||||||
): Promise<{ dims: Dimensions; updatedAt: Date }> {
|
): 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({
|
const row = await this.prisma.client.emotionState.upsert({
|
||||||
where: { characterId },
|
where: { characterId },
|
||||||
update: {},
|
update: {},
|
||||||
@@ -144,6 +191,7 @@ export class EmotionService {
|
|||||||
where: { characterId },
|
where: { characterId },
|
||||||
data: { ...dims, updatedAt: now },
|
data: { ...dims, updatedAt: now },
|
||||||
});
|
});
|
||||||
|
await this.writeCache(characterId, dims, now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { ConflictException, Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { SceneImportService, type ImportSceneInput } from "../canon/scene-import.service.js";
|
||||||
|
import { ProjectionService } from "../canon/projection.service.js";
|
||||||
|
import { TimeFlowService } from "./time-flow.service.js";
|
||||||
|
|
||||||
|
export interface ReclaimResult {
|
||||||
|
removedEpilogueScenes: number;
|
||||||
|
importedCanonScenes: number;
|
||||||
|
affectedCharacterIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// N-5 官方續篇出版時:正史回收(推演區間被正史替換,互動記憶保留+敘事化的記憶修正)
|
||||||
|
// 或分支保留(永久分岔,之後官方新內容都不匯入)。選擇權在使用者,這裡只提供兩條路徑各自的執行機制,
|
||||||
|
// 「依推演深淺給預設建議」是呼叫端(UI/未來的 Q 群組 App)的責任,不在引擎層決定。
|
||||||
|
@Injectable()
|
||||||
|
export class CanonReclamationService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly sceneImport: SceneImportService,
|
||||||
|
private readonly projection: ProjectionService,
|
||||||
|
private readonly timeFlow: TimeFlowService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async reclaim(workId: string, newCanonScenes: ImportSceneInput[]): Promise<ReclaimResult> {
|
||||||
|
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: workId } });
|
||||||
|
if (work.branchedFromCanon) {
|
||||||
|
throw new ConflictException("這個作品實例已經選擇分支保留,不再匯入官方新內容");
|
||||||
|
}
|
||||||
|
|
||||||
|
const minNewOrder = Math.min(...newCanonScenes.map((scene) => scene.storyOrder));
|
||||||
|
const superseded = await this.prisma.client.scene.findMany({
|
||||||
|
where: { workId, sourceType: "EPILOGUE", storyOrder: { gte: minNewOrder } },
|
||||||
|
include: { participants: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const affectedCharacterIds = new Set<string>();
|
||||||
|
for (const scene of superseded) {
|
||||||
|
for (const presence of scene.participants) {
|
||||||
|
affectedCharacterIds.add(presence.characterId);
|
||||||
|
}
|
||||||
|
// 清掉這場推演事件投影出的所有角色資料——同 M-7 reprojectScene 的刪除邏輯,一樣只篩
|
||||||
|
// sourceSceneId/sceneId,使用者互動記憶(source=INTERACTION)從不帶這些欄位,不會被動到。
|
||||||
|
await this.prisma.client.episodicMemory.deleteMany({ where: { sourceSceneId: scene.id } });
|
||||||
|
await this.prisma.client.semanticMemory.deleteMany({ where: { sourceSceneId: scene.id } });
|
||||||
|
await this.prisma.client.personalityTraitSnapshot.deleteMany({ where: { sourceSceneId: scene.id } });
|
||||||
|
await this.prisma.client.characterRelationshipEventLog.deleteMany({ where: { sceneId: scene.id } });
|
||||||
|
await this.prisma.client.sceneLine.deleteMany({ where: { sceneId: scene.id } });
|
||||||
|
await this.prisma.client.scenePresence.deleteMany({ where: { sceneId: scene.id } });
|
||||||
|
await this.prisma.client.scene.delete({ where: { id: scene.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 敘事化消化:不假裝那段推演的人生沒發生過,而是讓她「發現其實不是這樣」。
|
||||||
|
for (const characterId of affectedCharacterIds) {
|
||||||
|
await this.prisma.client.episodicMemory.create({
|
||||||
|
data: {
|
||||||
|
characterId,
|
||||||
|
content: "官方後來公開了那段時間真正發生的事,跟我原本以為的不太一樣……不過沒關係,那也是我曾經這樣想過的證明。",
|
||||||
|
occurredAt: new Date(),
|
||||||
|
emotionTag: "CALM",
|
||||||
|
emotionIntensity: 0.4,
|
||||||
|
source: "CORRECTION",
|
||||||
|
weight: 1.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 這些場景取代的是「已經活過」的後日談時段,正史錨定點的知識邊界對它們沒有意義——即使場景
|
||||||
|
// storyOrder 超過目前的 anchorStoryOrder(後日談本來就走在正史錨定點之前),也要直接投影,
|
||||||
|
// 並把錨定點一併推進到覆蓋這批正史內容,讓「正史知識邊界」與「已經投影的正史」保持一致。
|
||||||
|
let maxImportedOrder = work.anchorStoryOrder;
|
||||||
|
for (const sceneInput of newCanonScenes) {
|
||||||
|
const scene = await this.sceneImport.importScene(sceneInput);
|
||||||
|
if (!scene.projected) {
|
||||||
|
await this.projection.projectScene(scene.id);
|
||||||
|
}
|
||||||
|
maxImportedOrder = Math.max(maxImportedOrder, scene.storyOrder);
|
||||||
|
}
|
||||||
|
if (maxImportedOrder > work.anchorStoryOrder) {
|
||||||
|
await this.prisma.client.work.update({ where: { id: workId }, data: { anchorStoryOrder: maxImportedOrder } });
|
||||||
|
}
|
||||||
|
await this.timeFlow.checkEpilogueTransition(workId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
removedEpilogueScenes: superseded.length,
|
||||||
|
importedCanonScenes: newCanonScenes.length,
|
||||||
|
affectedCharacterIds: [...affectedCharacterIds],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async branch(workId: string) {
|
||||||
|
return this.prisma.client.work.update({ where: { id: workId }, data: { branchedFromCanon: true } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// N-1 時間流速:系統文件本身只定義了即時同步/緩速/凍結三種模式,皆不超過現實 1:1;
|
||||||
|
// 這裡額外加一個「加速」選項給一般角色使用(給使用者跳過等待的彈性),
|
||||||
|
// 後日談自然成年角色的「流速上限為現實 1:1」規則就是靠禁用這個選項來實作與驗收的。
|
||||||
|
export type TimeFlowMode = "FAST" | "REALTIME" | "SLOW" | "FROZEN";
|
||||||
|
export const TIME_FLOW_MODES: TimeFlowMode[] = ["FAST", "REALTIME", "SLOW", "FROZEN"];
|
||||||
|
|
||||||
|
// N-4 人生階段推演:重大轉折需低頻——同一角色兩次里程碑之間至少要間隔這麼多個年表單位。
|
||||||
|
export const MILESTONE_MIN_STORY_GAP = 5;
|
||||||
|
|
||||||
|
// N-2 性格漂移:方向由正史成長軌跡外插,但幅度必須有上限(性格核心鎖定,不是改寫)。
|
||||||
|
export const MAX_EXPRESSIVENESS_DRIFT = 0.15;
|
||||||
|
export const MAX_HALF_LIFE_REDUCTION = 0.3; // 情緒衰減最多加快 30%
|
||||||
|
export const HALF_LIFE_REDUCTION_PER_STORY_UNIT = 0.01;
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||||
|
import { TimeFlowService } from "./time-flow.service.js";
|
||||||
|
import { PersonalityDriftService } from "./personality-drift.service.js";
|
||||||
|
import { LifeEventService } from "./life-event.service.js";
|
||||||
|
import { CanonReclamationService } from "./canon-reclamation.service.js";
|
||||||
|
import type { TimeFlowMode } from "./constants.js";
|
||||||
|
import type { ImportSceneInput } from "../canon/scene-import.service.js";
|
||||||
|
|
||||||
|
@Controller("epilogue")
|
||||||
|
export class EpilogueController {
|
||||||
|
constructor(
|
||||||
|
private readonly timeFlow: TimeFlowService,
|
||||||
|
private readonly drift: PersonalityDriftService,
|
||||||
|
private readonly lifeEvent: LifeEventService,
|
||||||
|
private readonly reclamation: CanonReclamationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post("works/:workId/check-transition")
|
||||||
|
async checkTransition(@Param("workId") workId: string) {
|
||||||
|
return this.timeFlow.checkEpilogueTransition(workId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("works/:workId/time-flow")
|
||||||
|
async setTimeFlow(@Param("workId") workId: string, @Body() body: { mode: TimeFlowMode }) {
|
||||||
|
return this.timeFlow.setTimeFlow(workId, body.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("characters/:characterId/drift")
|
||||||
|
async getDrift(@Param("characterId") characterId: string, @Query("atStoryOrder") atStoryOrder: string) {
|
||||||
|
return this.drift.computeDrift(characterId, Number(atStoryOrder));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("characters/:characterId/milestone")
|
||||||
|
async proposeMilestone(
|
||||||
|
@Param("characterId") characterId: string,
|
||||||
|
@Body() body: { storyOrder: number; attendeeCharacterIds?: string[]; now?: string },
|
||||||
|
) {
|
||||||
|
return this.lifeEvent.proposeMilestone(
|
||||||
|
characterId,
|
||||||
|
body.storyOrder,
|
||||||
|
body.attendeeCharacterIds ?? [],
|
||||||
|
body.now ? new Date(body.now) : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("works/:workId/reclaim")
|
||||||
|
async reclaim(@Param("workId") workId: string, @Body() body: { newCanonScenes: ImportSceneInput[] }) {
|
||||||
|
return this.reclamation.reclaim(workId, body.newCanonScenes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("works/:workId/branch")
|
||||||
|
async branch(@Param("workId") workId: string) {
|
||||||
|
return this.reclamation.branch(workId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||||
|
import { RomanceModule } from "../romance/romance.module.js";
|
||||||
|
import { CanonModule } from "../canon/canon.module.js";
|
||||||
|
import { TimeFlowService } from "./time-flow.service.js";
|
||||||
|
import { PersonalityDriftService } from "./personality-drift.service.js";
|
||||||
|
import { LifeEventService } from "./life-event.service.js";
|
||||||
|
import { CanonReclamationService } from "./canon-reclamation.service.js";
|
||||||
|
import { EpilogueController } from "./epilogue.controller.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, RomanceModule, CanonModule],
|
||||||
|
controllers: [EpilogueController],
|
||||||
|
providers: [TimeFlowService, PersonalityDriftService, LifeEventService, CanonReclamationService],
|
||||||
|
exports: [TimeFlowService, PersonalityDriftService, LifeEventService, CanonReclamationService],
|
||||||
|
})
|
||||||
|
export class EpilogueModule {}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { ProjectionService } from "../canon/projection.service.js";
|
||||||
|
import { MILESTONE_MIN_STORY_GAP } from "./constants.js";
|
||||||
|
|
||||||
|
export interface MilestoneTemplate {
|
||||||
|
keyword: string;
|
||||||
|
label: string;
|
||||||
|
describe: (formalName: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// N-4 人生階段推演:依角色在正史裡確立的目標推演里程碑——沒有目標文字可對應的角色不生成重大轉折,
|
||||||
|
// 避免無正史依據地捏造事件(憑空轉向)。
|
||||||
|
const MILESTONE_TEMPLATES: MilestoneTemplate[] = [
|
||||||
|
{ keyword: "麵包店", label: "籌備麵包店", describe: (name) => `${name}開始四處看店面,籌備她一直想開的麵包店。` },
|
||||||
|
{ keyword: "麵包店", label: "麵包店開幕", describe: (name) => `${name}的麵包店終於開幕了,雖然忙得團團轉,但看起來很幸福。` },
|
||||||
|
{ keyword: "考試", label: "考上目標學校", describe: (name) => `${name}收到了合格通知,離她的目標又近了一步。` },
|
||||||
|
{ keyword: "畫家", label: "個展", describe: (name) => `${name}辦了第一次個人畫展,邀請了熟悉的朋友們。` },
|
||||||
|
];
|
||||||
|
|
||||||
|
// N-3/N-4 人生階段推演與角色群同步成長:生成的里程碑直接重用 M 群組的場景資料庫與投影機制——
|
||||||
|
// 一場戲的在場者是誰,誰就會得到對應的情節記憶,這正是「A 的婚禮 B 有出席」的實作基礎,
|
||||||
|
// 不需要另外寫一套「多角色事件廣播」機制。
|
||||||
|
@Injectable()
|
||||||
|
export class LifeEventService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly projection: ProjectionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private matchTemplate(goalsObsessions: string, excludeLabels: Set<string>): MilestoneTemplate | null {
|
||||||
|
for (const template of MILESTONE_TEMPLATES) {
|
||||||
|
if (goalsObsessions.includes(template.keyword) && !excludeLabels.has(template.label)) {
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 低頻節流 + 需要正史伏筆(goalsObsessions 裡要能對應到模板關鍵字)才會生成重大轉折。
|
||||||
|
async proposeMilestone(
|
||||||
|
characterId: string,
|
||||||
|
storyOrder: number,
|
||||||
|
attendeeCharacterIds: string[] = [],
|
||||||
|
now: Date = new Date(),
|
||||||
|
): Promise<{ scene: Awaited<ReturnType<PrismaService["client"]["scene"]["create"]>> | null; label: string | null }> {
|
||||||
|
const lastMilestone = await this.prisma.client.epilogueMilestoneLog.findFirst({
|
||||||
|
where: { characterId },
|
||||||
|
orderBy: { storyOrder: "desc" },
|
||||||
|
});
|
||||||
|
if (lastMilestone && storyOrder - lastMilestone.storyOrder < MILESTONE_MIN_STORY_GAP) {
|
||||||
|
return { scene: null, label: null }; // 低頻:離上次里程碑太近,不生成。
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||||
|
const usedLabels = new Set(
|
||||||
|
(await this.prisma.client.epilogueMilestoneLog.findMany({ where: { characterId } })).map((log) => log.label),
|
||||||
|
);
|
||||||
|
const template = this.matchTemplate(character.goalsObsessions, usedLabels);
|
||||||
|
if (!template) {
|
||||||
|
return { scene: null, label: null }; // 沒有正史伏筆可對應,不憑空捏造重大轉折。
|
||||||
|
}
|
||||||
|
|
||||||
|
const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId } });
|
||||||
|
const formalName = alias?.formalName ?? character.id;
|
||||||
|
const description = template.describe(formalName);
|
||||||
|
|
||||||
|
const scene = await this.generateSharedEvent(
|
||||||
|
character.workId!,
|
||||||
|
characterId,
|
||||||
|
storyOrder,
|
||||||
|
description,
|
||||||
|
attendeeCharacterIds,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.prisma.client.epilogueMilestoneLog.create({ data: { characterId, storyOrder, label: template.label } });
|
||||||
|
return { scene, label: template.label };
|
||||||
|
}
|
||||||
|
|
||||||
|
// N-3 角色群同步成長:一個推演事件的在場者可以是多個角色,投影後每個在場者都拿到對應的情節記憶,
|
||||||
|
// 敘述互相呼應(同一份場景摘要)。事件生成即代表「現在」,不受 M-9 錨定點前置判斷節制(不會超前於現在)。
|
||||||
|
async generateSharedEvent(
|
||||||
|
workId: string,
|
||||||
|
primaryCharacterId: string,
|
||||||
|
storyOrder: number,
|
||||||
|
description: string,
|
||||||
|
attendeeCharacterIds: string[],
|
||||||
|
now: Date = new Date(),
|
||||||
|
) {
|
||||||
|
const allPresent = [primaryCharacterId, ...attendeeCharacterIds.filter((id) => id !== primaryCharacterId)];
|
||||||
|
const scene = await this.prisma.client.scene.create({
|
||||||
|
data: {
|
||||||
|
workId,
|
||||||
|
volume: 0,
|
||||||
|
chapterLabel: "後日談",
|
||||||
|
storyOrder,
|
||||||
|
sourceType: "EPILOGUE",
|
||||||
|
summary: description,
|
||||||
|
rawText: description,
|
||||||
|
createdAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const characterId of allPresent) {
|
||||||
|
await this.prisma.client.scenePresence.create({ data: { sceneId: scene.id, characterId } });
|
||||||
|
}
|
||||||
|
await this.prisma.client.sceneLine.create({
|
||||||
|
data: { sceneId: scene.id, lineIndex: 0, lineType: "NARRATION", rawText: description, status: "CONFIRMED", confidence: 1 },
|
||||||
|
});
|
||||||
|
await this.projection.projectScene(scene.id);
|
||||||
|
return this.prisma.client.scene.findUniqueOrThrow({ where: { id: scene.id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { getArchetypeParams } from "../personality/archetype-params.js";
|
||||||
|
import { MAX_EXPRESSIVENESS_DRIFT, MAX_HALF_LIFE_REDUCTION, HALF_LIFE_REDUCTION_PER_STORY_UNIT } from "./constants.js";
|
||||||
|
|
||||||
|
export interface DriftedPersonality {
|
||||||
|
archetype: string; // 性格核心,從不改變
|
||||||
|
expressiveness: number; // 已套用漂移的外顯度(G-1 原始值 ± 小幅位移)
|
||||||
|
driftAmount: number; // 這次位移了多少(可為負)
|
||||||
|
emotionHalfLifeMultiplier: number; // <1 代表情緒衰減變快
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(value: number): number {
|
||||||
|
return Math.max(0, Math.min(1, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// N-2 性格漂移:性格核心(原型本身)鎖定不變,只有數值參數依正史已顯現的成長軌跡外插做小幅位移——
|
||||||
|
// 沒有證據支持的方向不會憑空轉向(沒有至少兩筆正史快照時,漂移量為 0)。
|
||||||
|
@Injectable()
|
||||||
|
export class PersonalityDriftService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async computeDrift(characterId: string, atStoryOrder: number): Promise<DriftedPersonality> {
|
||||||
|
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||||
|
const base = getArchetypeParams(character.personalityArchetype);
|
||||||
|
|
||||||
|
const canonSnapshots = await this.prisma.client.personalityTraitSnapshot.findMany({
|
||||||
|
where: { characterId, expressivenessOverride: { not: null } },
|
||||||
|
orderBy: { atStoryOrder: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
let trend = 0;
|
||||||
|
let referenceOrder = atStoryOrder;
|
||||||
|
if (canonSnapshots.length >= 2) {
|
||||||
|
const first = canonSnapshots[0];
|
||||||
|
const last = canonSnapshots[canonSnapshots.length - 1];
|
||||||
|
if (last.atStoryOrder !== first.atStoryOrder) {
|
||||||
|
trend = (last.expressivenessOverride! - first.expressivenessOverride!) / (last.atStoryOrder - first.atStoryOrder);
|
||||||
|
}
|
||||||
|
referenceOrder = last.atStoryOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Math.max(0, atStoryOrder - referenceOrder);
|
||||||
|
const rawDrift = trend * elapsed;
|
||||||
|
const driftAmount = Math.max(-MAX_EXPRESSIVENESS_DRIFT, Math.min(MAX_EXPRESSIVENESS_DRIFT, rawDrift));
|
||||||
|
const expressiveness = clamp01(base.expressiveness + driftAmount);
|
||||||
|
|
||||||
|
const halfLifeReduction = Math.min(MAX_HALF_LIFE_REDUCTION, elapsed * HALF_LIFE_REDUCTION_PER_STORY_UNIT);
|
||||||
|
|
||||||
|
return {
|
||||||
|
archetype: character.personalityArchetype,
|
||||||
|
expressiveness,
|
||||||
|
driftAmount,
|
||||||
|
emotionHalfLifeMultiplier: 1 - halfLifeReduction,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { RomanceEligibilityService } from "../romance/romance-eligibility.service.js";
|
||||||
|
import type { TimeFlowMode } from "./constants.js";
|
||||||
|
|
||||||
|
// N-1 區間切換與時間流速:錨定點抵達已出版內容盡頭後切入後日談;後日談成年角色的流速上限為現實 1:1,
|
||||||
|
// 只能調慢或凍結——這條規則在引擎層執行(不是使用者介面隱藏選項),任何嘗試設定更快流速都會被拒絕。
|
||||||
|
@Injectable()
|
||||||
|
export class TimeFlowService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly eligibility: RomanceEligibilityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// 錨定點推進後呼叫:若已達到(或超過)已出版內容的盡頭,切入後日談區間。
|
||||||
|
async checkEpilogueTransition(workId: string) {
|
||||||
|
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: workId } });
|
||||||
|
if (work.inEpilogue) {
|
||||||
|
return work;
|
||||||
|
}
|
||||||
|
const canonMax = await this.prisma.client.scene.aggregate({
|
||||||
|
where: { workId, sourceType: { not: "EPILOGUE" } },
|
||||||
|
_max: { storyOrder: true },
|
||||||
|
});
|
||||||
|
const maxCanonStoryOrder = canonMax._max.storyOrder ?? 0;
|
||||||
|
if (maxCanonStoryOrder > 0 && work.anchorStoryOrder >= maxCanonStoryOrder) {
|
||||||
|
return this.prisma.client.work.update({
|
||||||
|
where: { id: work.id },
|
||||||
|
data: { inEpilogue: true, epilogueStartStoryOrder: work.anchorStoryOrder },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return work;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setTimeFlow(workId: string, mode: TimeFlowMode) {
|
||||||
|
if (mode === "FAST") {
|
||||||
|
const characters = await this.prisma.client.character.findMany({ where: { workId } });
|
||||||
|
for (const character of characters) {
|
||||||
|
const eligibility = await this.eligibility.getEligibility(character.id);
|
||||||
|
if (eligibility.isEpilogueAdult) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`角色 ${character.id} 是正史未成年、後日談推演成年——時間流速上限為現實 1:1,不可設定快轉`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.prisma.client.work.update({ where: { id: workId }, data: { timeFlowMode: mode } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,101 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import type { LLMProvider } from "./llm-provider.js";
|
import type { LLMProvider } from "./llm-provider.js";
|
||||||
import type { GenerationContext, GeneratedResponse } from "./types.js";
|
import type { GenerationContext, GeneratedResponse } from "./types.js";
|
||||||
|
import { buildMessages } from "./prompt-builder.js";
|
||||||
|
import { extractActions } from "./action-markup.js";
|
||||||
|
|
||||||
const NOT_IMPLEMENTED_MESSAGE = "ClaudeProvider 尚未實作(R-3 會接上真實 Claude API)";
|
interface OpenAiCompatibleChoice {
|
||||||
|
message?: { content?: string };
|
||||||
|
delta?: { content?: string };
|
||||||
|
}
|
||||||
|
interface OpenAiCompatibleResponse {
|
||||||
|
choices?: OpenAiCompatibleChoice[];
|
||||||
|
}
|
||||||
|
|
||||||
// F-2 ClaudeProvider 空殼:R-3 才會實作真實呼叫,目前僅回報未實作。
|
function baseUrl(): string {
|
||||||
|
return process.env.CLAUDE_BASE_URL ?? "http://localhost:3000/api/v1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiKey(): string {
|
||||||
|
const key = process.env.CLAUDE_API_KEY;
|
||||||
|
if (!key) {
|
||||||
|
throw new Error("CLAUDE_API_KEY 未設定,LLM_PROVIDER=claude 需要這個環境變數才能呼叫真實 API");
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function model(): string {
|
||||||
|
return process.env.CLAUDE_MODEL ?? "claude-sonnet-4-5";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestChatCompletion(body: Record<string, unknown>): Promise<Response> {
|
||||||
|
const res = await fetch(`${baseUrl()}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey()}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`ClaudeProvider 呼叫失敗(HTTP ${res.status}):${text.slice(0, 500)}`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-3:把 F-2 的空殼換成真的呼叫。這個部署走 CLIProxy(分散式派工代理,見
|
||||||
|
// gitea.jsc.idv.tw/jiantw83/CLIProxy)提供的 OpenAI 相容端點,而不是直連
|
||||||
|
// api.anthropic.com——底層仍是 Claude,只是多一層派工代理,對這個介面
|
||||||
|
// (LLMProvider.generate/stream)完全透明,切換供應商不需要動任何呼叫端程式碼。
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ClaudeProvider implements LLMProvider {
|
export class ClaudeProvider implements LLMProvider {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
async generate(context: GenerationContext): Promise<GeneratedResponse> {
|
||||||
async generate(_context: GenerationContext): Promise<GeneratedResponse> {
|
const res = await requestChatCompletion({
|
||||||
throw new Error(NOT_IMPLEMENTED_MESSAGE);
|
model: model(),
|
||||||
|
messages: buildMessages(context),
|
||||||
|
stream: false,
|
||||||
|
});
|
||||||
|
const json = (await res.json()) as OpenAiCompatibleResponse;
|
||||||
|
const text = json.choices?.[0]?.message?.content ?? "";
|
||||||
|
if (!text) {
|
||||||
|
throw new Error("ClaudeProvider 回應內容為空(choices[0].message.content 缺失)");
|
||||||
|
}
|
||||||
|
return { text, actions: extractActions(text) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, require-yield
|
async *stream(context: GenerationContext): AsyncIterable<string> {
|
||||||
async *stream(_context: GenerationContext): AsyncIterable<string> {
|
const res = await requestChatCompletion({
|
||||||
throw new Error(NOT_IMPLEMENTED_MESSAGE);
|
model: model(),
|
||||||
|
messages: buildMessages(context),
|
||||||
|
stream: true,
|
||||||
|
});
|
||||||
|
if (!res.body) {
|
||||||
|
throw new Error("ClaudeProvider 串流回應沒有 body");
|
||||||
|
}
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith("data:")) continue;
|
||||||
|
const payload = trimmed.slice("data:".length).trim();
|
||||||
|
if (payload === "[DONE]") return;
|
||||||
|
let chunk: OpenAiCompatibleResponse;
|
||||||
|
try {
|
||||||
|
chunk = JSON.parse(payload);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const delta = chunk.choices?.[0]?.delta?.content;
|
||||||
|
if (delta) yield delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export class ContextAssemblerService {
|
|||||||
? []
|
? []
|
||||||
: await this.retriever.retrieve(characterId, userInput, { relatedUserId: userId });
|
: await this.retriever.retrieve(characterId, userInput, { relatedUserId: userId });
|
||||||
|
|
||||||
const history = this.workingMemory.getContext(sessionId).map((entry) => ({
|
const history = (await this.workingMemory.getContext(sessionId)).map((entry) => ({
|
||||||
role: entry.role,
|
role: entry.role,
|
||||||
content: entry.content,
|
content: entry.content,
|
||||||
timestamp: entry.timestamp.toISOString(),
|
timestamp: entry.timestamp.toISOString(),
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { Inject, Injectable } from "@nestjs/common";
|
import { Inject, Injectable } from "@nestjs/common";
|
||||||
import { PrismaService } from "../prisma/prisma.service.js";
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
import { WorkingMemoryService } from "../memory/working-memory.service.js";
|
import { WorkingMemoryService } from "../memory/working-memory.service.js";
|
||||||
|
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
||||||
|
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||||
|
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||||
|
import { ScheduleExceptionService } from "../schedule/schedule-exception.service.js";
|
||||||
|
import { ScheduleEmotionBaselineService } from "../schedule/schedule-emotion-baseline.service.js";
|
||||||
|
import { OfflineEventService } from "../schedule/offline-event.service.js";
|
||||||
|
import { availabilityForStatus, type AvailabilityMode, type ExceptionType } from "../schedule/constants.js";
|
||||||
import { LLM_PROVIDER, type LLMProvider } from "./llm-provider.js";
|
import { LLM_PROVIDER, type LLMProvider } from "./llm-provider.js";
|
||||||
import { ContextAssemblerService } from "./context-assembler.service.js";
|
import { ContextAssemblerService } from "./context-assembler.service.js";
|
||||||
import { OutputFilterService } from "./output-filter.service.js";
|
import { OutputFilterService } from "./output-filter.service.js";
|
||||||
@@ -18,15 +25,19 @@ export interface HandleMessageResult {
|
|||||||
text: string;
|
text: string;
|
||||||
actions: string[];
|
actions: string[];
|
||||||
isFastChannel: boolean;
|
isFastChannel: boolean;
|
||||||
context: GenerationContext;
|
availability: AvailabilityMode;
|
||||||
|
exceptionType: ExceptionType | null;
|
||||||
|
context: GenerationContext | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fastChannelSituation(reason: string): string {
|
function fastChannelSituation(reason: string): string {
|
||||||
return `fast-channel:${reason}`;
|
return `fast-channel:${reason}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DELAYED_ACKNOWLEDGEMENT = "抱歉剛剛在忙,現在才看到——";
|
||||||
|
|
||||||
// F 群組整合入口:串起雙速通道判斷、上下文組裝、Provider 生成、輸出過濾與工作記憶寫入。
|
// F 群組整合入口:串起雙速通道判斷、上下文組裝、Provider 生成、輸出過濾與工作記憶寫入。
|
||||||
// H-1 的 POST /chat/:characterId 會以此為基礎擴充(見 todo.md 實作記錄)。
|
// I 群組擴充:作息可用度/破例規則/作息情緒基線/離線事件生成(見 todo.md 實作記錄)。
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DialogueService {
|
export class DialogueService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -35,6 +46,12 @@ export class DialogueService {
|
|||||||
private readonly contextAssembler: ContextAssemblerService,
|
private readonly contextAssembler: ContextAssemblerService,
|
||||||
private readonly outputFilter: OutputFilterService,
|
private readonly outputFilter: OutputFilterService,
|
||||||
private readonly workingMemory: WorkingMemoryService,
|
private readonly workingMemory: WorkingMemoryService,
|
||||||
|
private readonly emotion: EmotionService,
|
||||||
|
private readonly relationship: RelationshipService,
|
||||||
|
private readonly schedule: ScheduleService,
|
||||||
|
private readonly scheduleException: ScheduleExceptionService,
|
||||||
|
private readonly scheduleBaseline: ScheduleEmotionBaselineService,
|
||||||
|
private readonly offlineEvent: OfflineEventService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -46,12 +63,48 @@ export class DialogueService {
|
|||||||
options: HandleMessageOptions = {},
|
options: HandleMessageOptions = {},
|
||||||
): Promise<HandleMessageResult> {
|
): Promise<HandleMessageResult> {
|
||||||
const now = options.now ?? new Date();
|
const now = options.now ?? new Date();
|
||||||
this.workingMemory.append(sessionId, { role: "user", content: userInput, timestamp: now });
|
await this.workingMemory.append(sessionId, { role: "user", content: userInput, timestamp: now });
|
||||||
|
|
||||||
|
// I-6:按需生成離線事件(今天只生成一次),成為話題來源。
|
||||||
|
await this.offlineEvent.maybeGenerate(characterId, now);
|
||||||
|
|
||||||
|
// I-5:作息驅動的情緒基線,只在本輪沒有明確情緒訊號時才介入。
|
||||||
|
const baselineSignal = await this.scheduleBaseline.computeBaseline(characterId, now);
|
||||||
|
const emotionResult = await this.emotion.processInput(characterId, userInput, {
|
||||||
|
now,
|
||||||
|
baselineSignal: baselineSignal ?? undefined,
|
||||||
|
});
|
||||||
|
await this.relationship.recordInteraction(characterId, userId, now);
|
||||||
|
|
||||||
|
// I-2:目前時段狀態 → 回應可用度;I-4:條件符合且未在冷卻期時才破例。
|
||||||
|
const scheduleInfo = await this.schedule.getInfo(characterId, now);
|
||||||
|
const availability = availabilityForStatus(scheduleInfo.slot?.status ?? null);
|
||||||
|
|
||||||
|
let exceptionType: ExceptionType | null = null;
|
||||||
|
let effectiveAvailability = availability;
|
||||||
|
if (availability !== "NORMAL") {
|
||||||
|
const { relationship } = await this.relationship.getState(characterId, userId, now);
|
||||||
|
exceptionType = await this.scheduleException.tryApply(
|
||||||
|
characterId,
|
||||||
|
userId,
|
||||||
|
availability,
|
||||||
|
{ intimacy: relationship.intimacy, dominantEmotion: dominantState(emotionResult.state), userInput },
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
if (exceptionType) {
|
||||||
|
effectiveAvailability = "NORMAL";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveAvailability === "NO_REPLY") {
|
||||||
|
return { text: "", actions: [], isFastChannel: false, availability: effectiveAvailability, exceptionType, context: null };
|
||||||
|
}
|
||||||
|
|
||||||
const { isFastChannel, reason } = this.fastChannel.detect(userInput);
|
const { isFastChannel, reason } = this.fastChannel.detect(userInput);
|
||||||
|
const skipMemoryRetrieval = isFastChannel || effectiveAvailability === "BRIEF";
|
||||||
|
|
||||||
const context = await this.contextAssembler.assemble(characterId, userId, sessionId, userInput, {
|
const context = await this.contextAssembler.assemble(characterId, userId, sessionId, userInput, {
|
||||||
skipMemoryRetrieval: isFastChannel,
|
skipMemoryRetrieval,
|
||||||
seed: options.seed,
|
seed: options.seed,
|
||||||
now,
|
now,
|
||||||
});
|
});
|
||||||
@@ -59,7 +112,10 @@ export class DialogueService {
|
|||||||
let text: string;
|
let text: string;
|
||||||
let actions: string[];
|
let actions: string[];
|
||||||
|
|
||||||
if (isFastChannel && reason) {
|
if (effectiveAvailability === "DELAYED" && !exceptionType) {
|
||||||
|
text = DELAYED_ACKNOWLEDGEMENT;
|
||||||
|
actions = [];
|
||||||
|
} else if (isFastChannel && reason) {
|
||||||
const rule = await this.prisma.client.proceduralRule.findFirst({
|
const rule = await this.prisma.client.proceduralRule.findFirst({
|
||||||
where: { characterId, situation: fastChannelSituation(reason) },
|
where: { characterId, situation: fastChannelSituation(reason) },
|
||||||
orderBy: { weight: "desc" },
|
orderBy: { weight: "desc" },
|
||||||
@@ -72,13 +128,18 @@ export class DialogueService {
|
|||||||
actions = generated.actions;
|
actions = generated.actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// I-2 剛醒/睡前:以拖長語尾的簡單標記呈現迷糊演出(完整口吻交由模板庫日後擴充)。
|
||||||
|
if (scheduleInfo.isWaking || scheduleInfo.isPreSleep) {
|
||||||
|
text = `……${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
const forbiddenWords = getForbiddenWords(context.character.personalityArchetype);
|
const forbiddenWords = getForbiddenWords(context.character.personalityArchetype);
|
||||||
const filteredText = applySpeechQuirks(
|
const filteredText = applySpeechQuirks(
|
||||||
this.outputFilter.filter(text, forbiddenWords),
|
this.outputFilter.filter(text, forbiddenWords),
|
||||||
context.character.personalityArchetype,
|
context.character.personalityArchetype,
|
||||||
);
|
);
|
||||||
this.workingMemory.append(sessionId, { role: "character", content: filteredText, timestamp: now });
|
await this.workingMemory.append(sessionId, { role: "character", content: filteredText, timestamp: now });
|
||||||
|
|
||||||
return { text: filteredText, actions, isFastChannel, context };
|
return { text: filteredText, actions, isFastChannel, availability: effectiveAvailability, exceptionType, context };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { EmotionTag } from "@kokorone/shared";
|
||||||
|
import { pickTemplates } from "./template-library.js";
|
||||||
|
|
||||||
|
export interface FewShotExample {
|
||||||
|
emotionTag: EmotionTag;
|
||||||
|
tier: "low" | "high";
|
||||||
|
userInput: string;
|
||||||
|
response: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_USER_INPUT: Record<"low" | "high", string> = {
|
||||||
|
low: "今天天氣真好呢。",
|
||||||
|
high: "謝謝你,我今天好開心!",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SAMPLE_EMOTIONS: EmotionTag[] = ["CALM", "JOY", "SAD", "ALERT", "SHY", "GRUMPY"];
|
||||||
|
|
||||||
|
// R-3:F-7 的 Mock 模板庫在真實 Provider 這邊轉為 few-shot 範例——同一份模板資料,
|
||||||
|
// 從「隨機挑一句直接當回應」變成「示範這個性格原型在各種情緒下該有的語氣」。
|
||||||
|
export function buildFewShotExamples(personalityArchetype: string): FewShotExample[] {
|
||||||
|
const examples: FewShotExample[] = [];
|
||||||
|
for (const tier of ["low", "high"] as const) {
|
||||||
|
for (const emotionTag of SAMPLE_EMOTIONS) {
|
||||||
|
const candidates = pickTemplates(personalityArchetype, emotionTag, tier);
|
||||||
|
if (candidates.length === 0) continue;
|
||||||
|
examples.push({ emotionTag, tier, userInput: SAMPLE_USER_INPUT[tier], response: candidates[0] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return examples;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { PrismaModule } from "../prisma/prisma.module.js";
|
|||||||
import { EmotionModule } from "../emotion/emotion.module.js";
|
import { EmotionModule } from "../emotion/emotion.module.js";
|
||||||
import { RelationshipModule } from "../relationship/relationship.module.js";
|
import { RelationshipModule } from "../relationship/relationship.module.js";
|
||||||
import { MemoryModule } from "../memory/memory.module.js";
|
import { MemoryModule } from "../memory/memory.module.js";
|
||||||
|
import { ScheduleModule } from "../schedule/schedule.module.js";
|
||||||
import { MockProvider } from "./mock-provider.service.js";
|
import { MockProvider } from "./mock-provider.service.js";
|
||||||
import { ClaudeProvider } from "./claude-provider.service.js";
|
import { ClaudeProvider } from "./claude-provider.service.js";
|
||||||
import { LLM_PROVIDER } from "./llm-provider.js";
|
import { LLM_PROVIDER } from "./llm-provider.js";
|
||||||
@@ -15,17 +16,18 @@ import { DialogueService } from "./dialogue.service.js";
|
|||||||
import { DialogueController } from "./dialogue.controller.js";
|
import { DialogueController } from "./dialogue.controller.js";
|
||||||
|
|
||||||
// F-2 Provider 切換:LLM_PROVIDER=mock|claude 決定注入哪個實作,切換不動引擎任何一行。
|
// F-2 Provider 切換:LLM_PROVIDER=mock|claude 決定注入哪個實作,切換不動引擎任何一行。
|
||||||
|
// R-3:ClaudeProvider 已接上真實 API,這裡不再需要特別的「尚未實作」警告。
|
||||||
function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
|
function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
|
||||||
const providerName = process.env.LLM_PROVIDER ?? "mock";
|
const providerName = process.env.LLM_PROVIDER ?? "mock";
|
||||||
if (providerName === "claude") {
|
if (providerName === "claude") {
|
||||||
log("啟動", "ERR", "LLM_PROVIDER=claude 但 ClaudeProvider 尚未實作(R-3),對話生成呼叫時會拋出例外");
|
log("啟動", "INF", `LLM_PROVIDER=claude,對話生成將呼叫真實 API(CLAUDE_BASE_URL=${process.env.CLAUDE_BASE_URL ?? "http://localhost:3000/api/v1"})`);
|
||||||
return claude;
|
return claude;
|
||||||
}
|
}
|
||||||
return mock;
|
return mock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, EmotionModule, RelationshipModule, MemoryModule],
|
imports: [PrismaModule, EmotionModule, RelationshipModule, MemoryModule, ScheduleModule],
|
||||||
controllers: [DialogueController],
|
controllers: [DialogueController],
|
||||||
providers: [
|
providers: [
|
||||||
MockProvider,
|
MockProvider,
|
||||||
@@ -37,6 +39,6 @@ function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
|
|||||||
ContextAssemblerService,
|
ContextAssemblerService,
|
||||||
DialogueService,
|
DialogueService,
|
||||||
],
|
],
|
||||||
exports: [DialogueService, BehaviorReinforcementService],
|
exports: [DialogueService, BehaviorReinforcementService, LLM_PROVIDER],
|
||||||
})
|
})
|
||||||
export class LlmModule {}
|
export class LlmModule {}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ import { AppModule } from "./app.module.js";
|
|||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule, { logger: false });
|
const app = await NestFactory.create(AppModule, { logger: false });
|
||||||
|
// Q-1:8081 是 `expo start --web` 的預設埠——APP 在真機/模擬器上不受 CORS 限制(原生環境沒有這個瀏覽器機制),
|
||||||
|
// 只有用瀏覽器跑 Expo 的網頁版時才需要放行,開發環境固定加這個來源即可。
|
||||||
|
app.enableCors({ origin: [process.env.WEB_ORIGIN ?? "http://localhost:3100", "http://localhost:8081"] });
|
||||||
const port = Number(process.env.PORT ?? 3001);
|
const port = Number(process.env.PORT ?? 3001);
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
log("啟動", "INF", `api 服務已啟動,監聽埠 ${port}`);
|
log("啟動", "INF", `api 服務已啟動,監聽埠 ${port}`);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export class MemoryConsolidationService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async consolidate(characterId: string, sessionId: string): Promise<void> {
|
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>();
|
const contentCounts = new Map<string, number>();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -53,6 +53,6 @@ export class MemoryConsolidationService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.workingMemory.clear(sessionId);
|
await this.workingMemory.clear(sessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface RetrieveOptions {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
// E-5 關係加權檢索接點:與此使用者相關的記憶會被加權排到前面。
|
// E-5 關係加權檢索接點:與此使用者相關的記憶會被加權排到前面。
|
||||||
relatedUserId?: string;
|
relatedUserId?: string;
|
||||||
|
// K-3 隱私邊界:群聊等有旁人在場的情境傳 true,標記為私密的記憶不會被檢索到。
|
||||||
|
excludePrivate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemoryRetriever {
|
export interface MemoryRetriever {
|
||||||
@@ -37,7 +39,9 @@ export class KeywordMemoryRetriever implements MemoryRetriever {
|
|||||||
|
|
||||||
async retrieve(characterId: string, query: string, options: RetrieveOptions = {}): Promise<EpisodicMemory[]> {
|
async retrieve(characterId: string, query: string, options: RetrieveOptions = {}): Promise<EpisodicMemory[]> {
|
||||||
const limit = options.limit ?? DEFAULT_LIMIT;
|
const limit = options.limit ?? DEFAULT_LIMIT;
|
||||||
const rows = await this.prisma.client.episodicMemory.findMany({ where: { characterId } });
|
const rows = await this.prisma.client.episodicMemory.findMany({
|
||||||
|
where: { characterId, ...(options.excludePrivate ? { isPrivate: false } : {}) },
|
||||||
|
});
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const keywords = query.split(/\s+/).filter(Boolean);
|
const keywords = query.split(/\s+/).filter(Boolean);
|
||||||
|
|
||||||
@@ -76,6 +80,7 @@ export class KeywordMemoryRetriever implements MemoryRetriever {
|
|||||||
lastRetrievedAt: new Date(now).toISOString(),
|
lastRetrievedAt: new Date(now).toISOString(),
|
||||||
source: row.source,
|
source: row.source,
|
||||||
weight: row.weight,
|
weight: row.weight,
|
||||||
|
isPrivate: row.isPrivate,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,20 +27,20 @@ export class MemoryController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post(":characterId/sessions/:sessionId/messages")
|
@Post(":characterId/sessions/:sessionId/messages")
|
||||||
appendMessage(@Param("sessionId") sessionId: string, @Body() body: AppendMessageBody) {
|
async appendMessage(@Param("sessionId") sessionId: string, @Body() body: AppendMessageBody) {
|
||||||
this.workingMemory.append(sessionId, {
|
await this.workingMemory.append(sessionId, {
|
||||||
role: body.role,
|
role: body.role,
|
||||||
content: body.content,
|
content: body.content,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
emotionTag: body.emotionTag,
|
emotionTag: body.emotionTag,
|
||||||
emotionIntensity: body.emotionIntensity,
|
emotionIntensity: body.emotionIntensity,
|
||||||
});
|
});
|
||||||
return { context: this.workingMemory.getContext(sessionId) };
|
return { context: await this.workingMemory.getContext(sessionId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":characterId/sessions/:sessionId/messages")
|
@Get(":characterId/sessions/:sessionId/messages")
|
||||||
getContext(@Param("sessionId") sessionId: string) {
|
async getContext(@Param("sessionId") sessionId: string) {
|
||||||
return { context: this.workingMemory.getContext(sessionId) };
|
return { context: await this.workingMemory.getContext(sessionId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(":characterId/sessions/:sessionId/consolidate")
|
@Post(":characterId/sessions/:sessionId/consolidate")
|
||||||
@@ -55,10 +55,12 @@ export class MemoryController {
|
|||||||
@Query("query") query: string,
|
@Query("query") query: string,
|
||||||
@Query("limit") limit?: string,
|
@Query("limit") limit?: string,
|
||||||
@Query("relatedUserId") relatedUserId?: string,
|
@Query("relatedUserId") relatedUserId?: string,
|
||||||
|
@Query("excludePrivate") excludePrivate?: string,
|
||||||
) {
|
) {
|
||||||
const results = await this.retriever.retrieve(characterId, query ?? "", {
|
const results = await this.retriever.retrieve(characterId, query ?? "", {
|
||||||
limit: limit ? Number(limit) : undefined,
|
limit: limit ? Number(limit) : undefined,
|
||||||
relatedUserId,
|
relatedUserId,
|
||||||
|
excludePrivate: excludePrivate === "true",
|
||||||
});
|
});
|
||||||
return { results };
|
return { results };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import type { EmotionTag } from "@kokorone/shared";
|
import type { EmotionTag } from "@kokorone/shared";
|
||||||
import { HIGH_EMOTION_THRESHOLD } from "./constants.js";
|
import { HIGH_EMOTION_THRESHOLD } from "./constants.js";
|
||||||
|
import { getRedisClient } from "../cache/redis-client.js";
|
||||||
|
|
||||||
export interface WorkingMemoryEntry {
|
export interface WorkingMemoryEntry {
|
||||||
role: "user" | "character";
|
role: "user" | "character";
|
||||||
@@ -10,7 +11,14 @@ export interface WorkingMemoryEntry {
|
|||||||
emotionIntensity?: number; // 0~1,未標記情緒的訊息(如快速通道問候)可省略
|
emotionIntensity?: number; // 0~1,未標記情緒的訊息(如快速通道問候)可省略
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SerializedEntry extends Omit<WorkingMemoryEntry, "timestamp"> {
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_TOKEN_LIMIT = 200;
|
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 近似。
|
// 粗略估算:Mock 階段不需要真實 tokenizer,先以字元數/2 近似。
|
||||||
function estimateTokens(text: string): number {
|
function estimateTokens(text: string): number {
|
||||||
@@ -21,32 +29,11 @@ function isHighEmotion(entry: WorkingMemoryEntry): boolean {
|
|||||||
return (entry.emotionIntensity ?? 0) >= HIGH_EMOTION_THRESHOLD;
|
return (entry.emotionIntensity ?? 0) >= HIGH_EMOTION_THRESHOLD;
|
||||||
}
|
}
|
||||||
|
|
||||||
// C-1 工作記憶:session 對話上下文緩衝,只存在於行程記憶體中(如同海馬迴暫存),
|
function evictOverflow(entries: WorkingMemoryEntry[], tokenLimit: number): WorkingMemoryEntry[] {
|
||||||
// 不落地到任何長期記憶表——長期寫入只能由 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[] {
|
|
||||||
let result = entries;
|
let result = entries;
|
||||||
let totalTokens = result.reduce((sum, e) => sum + estimateTokens(e.content), 0);
|
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));
|
let evictIndex = result.findIndex((e) => !isHighEmotion(e));
|
||||||
if (evictIndex === -1) {
|
if (evictIndex === -1) {
|
||||||
@@ -57,5 +44,59 @@ export class WorkingMemoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
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 已經在上面清掉,沒有需要額外處理的狀態。
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ export interface ArchetypeParams {
|
|||||||
expressiveness: number; // 情緒外顯度 0~1:極低代表幾乎不顯露(三無),極高代表全寫在臉上(元氣)
|
expressiveness: number; // 情緒外顯度 0~1:極低代表幾乎不顯露(三無),極高代表全寫在臉上(元氣)
|
||||||
trustGrowthRate: number; // 信任成長速度倍率:套用在 E-2 正向事件的信任增幅上
|
trustGrowthRate: number; // 信任成長速度倍率:套用在 E-2 正向事件的信任增幅上
|
||||||
invertedIntimacyExpression: boolean; // 是否為傲嬌式反向表達(見 G-3)
|
invertedIntimacyExpression: boolean; // 是否為傲嬌式反向表達(見 G-3)
|
||||||
traits: string[]; // 特徵行為旗標,描述性標籤供其他機制(G-4/G-6)參考
|
traits: string[]; // 特徵行為旗標,描述性標籤供其他機制(G-4/G-6/K-3)參考
|
||||||
|
conversationBaseline: number; // K-4 群聊發言基線 0~1:不被點名、無特別理由時主動開口的傾向(元氣高、三無極低)
|
||||||
|
romanceGainRate: number; // L-3 戀愛軸基礎曲線速度倍率:套用在心動事件的增減幅上(冷淡極慢、元氣快)
|
||||||
|
romanceBigEventBonus: number; // L-3 高幅心動事件(共同經歷高情緒事件/被理解的瞬間)的額外倍率:>1 呈現「階梯狀跳升」(大小姐)
|
||||||
|
romanceAmbiguousStallFactor: number; // L-3 曖昧階段的額外速度倍率:<1 代表卡在曖昧期打轉、怕改變現狀(元氣)
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PARAMS: ArchetypeParams = {
|
const DEFAULT_PARAMS: ArchetypeParams = {
|
||||||
@@ -15,6 +19,10 @@ const DEFAULT_PARAMS: ArchetypeParams = {
|
|||||||
trustGrowthRate: 1,
|
trustGrowthRate: 1,
|
||||||
invertedIntimacyExpression: false,
|
invertedIntimacyExpression: false,
|
||||||
traits: [],
|
traits: [],
|
||||||
|
conversationBaseline: 0.4,
|
||||||
|
romanceGainRate: 1,
|
||||||
|
romanceBigEventBonus: 1,
|
||||||
|
romanceAmbiguousStallFactor: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||||
@@ -24,6 +32,11 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
|||||||
trustGrowthRate: 0.5,
|
trustGrowthRate: 0.5,
|
||||||
invertedIntimacyExpression: true,
|
invertedIntimacyExpression: true,
|
||||||
traits: ["denial-then-honest"],
|
traits: ["denial-then-honest"],
|
||||||
|
conversationBaseline: 0.5,
|
||||||
|
// 心動值與外顯敵意同步上升——外顯部分交給既有的 G-3 tsundereGap 機制處理,這裡的曲線本身走一般速度。
|
||||||
|
romanceGainRate: 1,
|
||||||
|
romanceBigEventBonus: 1,
|
||||||
|
romanceAmbiguousStallFactor: 1,
|
||||||
},
|
},
|
||||||
冷淡: {
|
冷淡: {
|
||||||
emotionTriggerThreshold: 2.5,
|
emotionTriggerThreshold: 2.5,
|
||||||
@@ -31,13 +44,22 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
|||||||
trustGrowthRate: 0.3,
|
trustGrowthRate: 0.3,
|
||||||
invertedIntimacyExpression: false,
|
invertedIntimacyExpression: false,
|
||||||
traits: ["flat-affect", "short-replies"],
|
traits: ["flat-affect", "short-replies"],
|
||||||
|
conversationBaseline: 0.15,
|
||||||
|
romanceGainRate: 0.4, // 極緩慢線性,無明顯階段感
|
||||||
|
romanceBigEventBonus: 1,
|
||||||
|
romanceAmbiguousStallFactor: 1,
|
||||||
},
|
},
|
||||||
天然呆: {
|
天然呆: {
|
||||||
emotionTriggerThreshold: 0.7,
|
emotionTriggerThreshold: 0.7,
|
||||||
expressiveness: 0.8,
|
expressiveness: 0.8,
|
||||||
trustGrowthRate: 1.5,
|
trustGrowthRate: 1.5,
|
||||||
invertedIntimacyExpression: false,
|
invertedIntimacyExpression: false,
|
||||||
traits: ["misreads-context"],
|
// K-3:口風不緊——群聊中偶爾會不小心把私密記憶說出來,是這個原型的性格設定例外。
|
||||||
|
traits: ["misreads-context", "loose-lipped"],
|
||||||
|
conversationBaseline: 0.6,
|
||||||
|
romanceGainRate: 1,
|
||||||
|
romanceBigEventBonus: 1,
|
||||||
|
romanceAmbiguousStallFactor: 1,
|
||||||
},
|
},
|
||||||
元氣: {
|
元氣: {
|
||||||
emotionTriggerThreshold: 0.7,
|
emotionTriggerThreshold: 0.7,
|
||||||
@@ -45,6 +67,10 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
|||||||
trustGrowthRate: 1.3,
|
trustGrowthRate: 1.3,
|
||||||
invertedIntimacyExpression: false,
|
invertedIntimacyExpression: false,
|
||||||
traits: ["talkative", "fast-emotion-decay"],
|
traits: ["talkative", "fast-emotion-decay"],
|
||||||
|
conversationBaseline: 0.85,
|
||||||
|
romanceGainRate: 1.3, // 快速上升
|
||||||
|
romanceBigEventBonus: 1,
|
||||||
|
romanceAmbiguousStallFactor: 0.3, // 但卡在曖昧期打轉——怕改變現狀
|
||||||
},
|
},
|
||||||
大小姐: {
|
大小姐: {
|
||||||
emotionTriggerThreshold: 1.3,
|
emotionTriggerThreshold: 1.3,
|
||||||
@@ -52,6 +78,10 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
|||||||
trustGrowthRate: 0.4,
|
trustGrowthRate: 0.4,
|
||||||
invertedIntimacyExpression: false,
|
invertedIntimacyExpression: false,
|
||||||
traits: ["formal-address", "poor-loser"],
|
traits: ["formal-address", "poor-loser"],
|
||||||
|
conversationBaseline: 0.55,
|
||||||
|
romanceGainRate: 0.5, // 平時矜持撐住,小事件幾乎不動
|
||||||
|
romanceBigEventBonus: 2.2, // 某事件後大幅跳升——階梯狀曲線
|
||||||
|
romanceAmbiguousStallFactor: 1,
|
||||||
},
|
},
|
||||||
三無: {
|
三無: {
|
||||||
emotionTriggerThreshold: 3.5,
|
emotionTriggerThreshold: 3.5,
|
||||||
@@ -59,6 +89,11 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
|||||||
trustGrowthRate: 0.2,
|
trustGrowthRate: 0.2,
|
||||||
invertedIntimacyExpression: false,
|
invertedIntimacyExpression: false,
|
||||||
traits: ["very-short-replies", "sudden-overflow"],
|
traits: ["very-short-replies", "sudden-overflow"],
|
||||||
|
conversationBaseline: 0.05,
|
||||||
|
// 內在曲線走一般速度(心動值本身正常累積),幾無外顯訊號是外顯度(expressiveness)的職責,不是曲線本身變慢。
|
||||||
|
romanceGainRate: 1,
|
||||||
|
romanceBigEventBonus: 1,
|
||||||
|
romanceAmbiguousStallFactor: 1,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ export class CharacterCreationService {
|
|||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
emotionTag: signal.tag,
|
emotionTag: signal.tag,
|
||||||
emotionIntensity: signal.intensity || 0.3,
|
emotionIntensity: signal.intensity || 0.3,
|
||||||
source: "OFFLINE_GENERATED",
|
// 來自角色設定表本身,不是 I-6 的「離線生成日常事件」,兩者不可共用同一 source 值
|
||||||
|
// (I-6 用 source=OFFLINE_GENERATED 判斷「今天是否已生成過離線事件」,混用會誤判)。
|
||||||
|
source: "SOURCE_EXTRACTION",
|
||||||
weight: 2,
|
weight: 2,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { PushProvider, PushMessage, PushSendResult } from "./push-provider.js";
|
||||||
|
|
||||||
|
const EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send";
|
||||||
|
|
||||||
|
interface ExpoPushResponse {
|
||||||
|
data?: { status?: string; message?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Q-3 真實推播供應商:Expo 的推播 API 是「React Native + Expo」這個技術選型本身自帶的服務
|
||||||
|
// (不像 LLM/TTS 需要另外挑一家外部供應商待人工確認),基本匿名發送不需要金鑰,所以這裡直接
|
||||||
|
// 實作真正會打出去的 HTTP 呼叫——沒有真實裝置 token 時,Expo 端會回報 token 無效,屬於預期行為,
|
||||||
|
// 不是這個 Provider 本身沒做完。
|
||||||
|
@Injectable()
|
||||||
|
export class ExpoPushProvider implements PushProvider {
|
||||||
|
async send(message: PushMessage): Promise<PushSendResult> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(EXPO_PUSH_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ to: message.token, title: message.title, body: message.body, data: message.data ?? {} }),
|
||||||
|
});
|
||||||
|
const payload = (await res.json().catch(() => null)) as ExpoPushResponse | null;
|
||||||
|
const ticket = payload?.data;
|
||||||
|
if (!res.ok || ticket?.status === "error") {
|
||||||
|
return { success: false, errorText: ticket?.message ?? `HTTP ${res.status}` };
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, errorText: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { PushProvider, PushMessage, PushSendResult } from "./push-provider.js";
|
||||||
|
|
||||||
|
// Q-3 MockPushProvider:不打真的網路請求,只記錄「這次呼叫本身有沒有正確組出訊息」——
|
||||||
|
// 讓推播的節流/記錄/與 J 群組的整合可以在沒有真實裝置與網路的情況下完整測試。
|
||||||
|
@Injectable()
|
||||||
|
export class MockPushProvider implements PushProvider {
|
||||||
|
public readonly sent: PushMessage[] = [];
|
||||||
|
|
||||||
|
async send(message: PushMessage): Promise<PushSendResult> {
|
||||||
|
this.sent.push(message);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Inject, Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { PushTokenService } from "./push-token.service.js";
|
||||||
|
import { PUSH_PROVIDER, type PushProvider } from "./push-provider.js";
|
||||||
|
|
||||||
|
export interface SendToUserResult {
|
||||||
|
attempted: number;
|
||||||
|
succeeded: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Q-3 推播通知:提醒觸發(J 群組)、角色主動訊息、鎖屏通知都走這個服務——同一個使用者可能有
|
||||||
|
// 多台裝置,逐一送出並各自留一筆記錄(成功或失敗皆記),方便之後追查「提醒有沒有真的送達」。
|
||||||
|
@Injectable()
|
||||||
|
export class PushNotificationService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly pushToken: PushTokenService,
|
||||||
|
@Inject(PUSH_PROVIDER) private readonly provider: PushProvider,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async sendToUser(userId: string, title: string, body: string, data?: Record<string, unknown>): Promise<SendToUserResult> {
|
||||||
|
const tokens = await this.pushToken.listForUser(userId);
|
||||||
|
let succeeded = 0;
|
||||||
|
for (const { token } of tokens) {
|
||||||
|
const result = await this.provider.send({ token, title, body, data });
|
||||||
|
await this.prisma.client.pushNotificationLog.create({
|
||||||
|
data: { userId, title, body, success: result.success, errorText: result.errorText },
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
succeeded += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { attempted: tokens.length, succeeded };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export interface PushMessage {
|
||||||
|
token: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushSendResult {
|
||||||
|
success: boolean;
|
||||||
|
errorText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Q-3 PushProvider 抽象:同一個模式的第三次套用(F-1 LLMProvider/P-4 TTSProvider),
|
||||||
|
// 切換 Provider 不動呼叫端任何一行。
|
||||||
|
export interface PushProvider {
|
||||||
|
send(message: PushMessage): Promise<PushSendResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PUSH_PROVIDER = Symbol("PUSH_PROVIDER");
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
|
||||||
|
// Q-3 裝置推播 token 註冊:同一個 token 重複註冊(例如 APP 重新啟動)視為更新,不會產生重複紀錄。
|
||||||
|
@Injectable()
|
||||||
|
export class PushTokenService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async register(userId: string, token: string, platform: string) {
|
||||||
|
return this.prisma.client.pushToken.upsert({
|
||||||
|
where: { token },
|
||||||
|
update: { userId, platform },
|
||||||
|
create: { userId, token, platform },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listForUser(userId: string) {
|
||||||
|
return this.prisma.client.pushToken.findMany({ where: { userId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Body, Controller, Post } from "@nestjs/common";
|
||||||
|
import { PushTokenService } from "./push-token.service.js";
|
||||||
|
import { PushNotificationService } from "./push-notification.service.js";
|
||||||
|
|
||||||
|
interface RegisterBody {
|
||||||
|
userId: string;
|
||||||
|
token: string;
|
||||||
|
platform: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendTestBody {
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller("push")
|
||||||
|
export class PushController {
|
||||||
|
constructor(
|
||||||
|
private readonly pushToken: PushTokenService,
|
||||||
|
private readonly pushNotification: PushNotificationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post("register")
|
||||||
|
async register(@Body() body: RegisterBody) {
|
||||||
|
return this.pushToken.register(body.userId, body.token, body.platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("send-test")
|
||||||
|
async sendTest(@Body() body: SendTestBody) {
|
||||||
|
return this.pushNotification.sendToUser(body.userId, body.title, body.body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { log } from "@kokorone/shared";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||||
|
import { PushTokenService } from "./push-token.service.js";
|
||||||
|
import { PushNotificationService } from "./push-notification.service.js";
|
||||||
|
import { MockPushProvider } from "./mock-push-provider.service.js";
|
||||||
|
import { ExpoPushProvider } from "./expo-push-provider.service.js";
|
||||||
|
import { PUSH_PROVIDER } from "./push-provider.js";
|
||||||
|
import { PushController } from "./push.controller.js";
|
||||||
|
|
||||||
|
// Q-3 Provider 切換:PUSH_PROVIDER=mock|expo,預設 mock(同 F-2/P-4 的作法,開發期不依賴外部服務)。
|
||||||
|
function pushProviderFactory(mock: MockPushProvider, expoProvider: ExpoPushProvider) {
|
||||||
|
const providerName = process.env.PUSH_PROVIDER ?? "mock";
|
||||||
|
if (providerName === "expo") {
|
||||||
|
log("啟動", "INF", "PUSH_PROVIDER=expo,推播會真的呼叫 Expo 推播 API(沒有真實裝置 token 時預期會失敗)");
|
||||||
|
return expoProvider;
|
||||||
|
}
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [PushController],
|
||||||
|
providers: [
|
||||||
|
PushTokenService,
|
||||||
|
PushNotificationService,
|
||||||
|
MockPushProvider,
|
||||||
|
ExpoPushProvider,
|
||||||
|
{ provide: PUSH_PROVIDER, useFactory: pushProviderFactory, inject: [MockPushProvider, ExpoPushProvider] },
|
||||||
|
],
|
||||||
|
exports: [PushTokenService, PushNotificationService, PUSH_PROVIDER, MockPushProvider],
|
||||||
|
})
|
||||||
|
export class PushModule {}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { CharacterAgeEvidenceType } from "@kokorone/db";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
|
||||||
|
export interface AgeDetermination {
|
||||||
|
canonAdult: boolean; // 只看正史(非後日談)證據:是否判定成年
|
||||||
|
currentAdult: boolean; // 含後日談推演證據:現在是否成年
|
||||||
|
isEpilogueAdult: boolean; // currentAdult && !canonAdult——正史未成年、後日談推演後才成年
|
||||||
|
}
|
||||||
|
|
||||||
|
// L-6 只有「決定性」或「強」證據能單獨判定成年;外觀與社會呈現(APPEARANCE)僅供輔助,
|
||||||
|
// 不採信「外表少女實為千年」類設定——不論其 impliesAdult 標記為何,永遠不足以單獨採信。
|
||||||
|
const DECISIVE_OR_STRONG: CharacterAgeEvidenceType[] = ["OFFICIAL_STATED", "IN_STORY_STATUS", "ELAPSED_TIME"];
|
||||||
|
|
||||||
|
// L-6 角色年齡判定:年齡是年表上的屬性,依證據優先序判定,證據不足一律從嚴按未成年處理。
|
||||||
|
@Injectable()
|
||||||
|
export class AgeDeterminationService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async determine(characterId: string): Promise<AgeDetermination> {
|
||||||
|
const evidence = await this.prisma.client.characterAgeEvidence.findMany({ where: { characterId } });
|
||||||
|
const canonAdult = this.isAdultFrom(evidence.filter((row) => !row.isEpilogue));
|
||||||
|
const currentAdult = this.isAdultFrom(evidence);
|
||||||
|
return { canonAdult, currentAdult, isEpilogueAdult: currentAdult && !canonAdult };
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAdultFrom(evidence: { evidenceType: CharacterAgeEvidenceType; impliesAdult: boolean }[]): boolean {
|
||||||
|
return evidence.some((row) => DECISIVE_OR_STRONG.includes(row.evidenceType) && row.impliesAdult);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
||||||
|
import { RomanceService } from "./romance.service.js";
|
||||||
|
import { RomanceEligibilityService } from "./romance-eligibility.service.js";
|
||||||
|
import {
|
||||||
|
CONFESSION_METER_THRESHOLD,
|
||||||
|
AWKWARD_PERIOD_DAYS,
|
||||||
|
REJECTION_DAMAGE_THRESHOLD,
|
||||||
|
REJECTION_DAMAGE_METER_PENALTY,
|
||||||
|
IMMEDIATE_RECONFESS_PENALTY,
|
||||||
|
BAD_TIMING_DISAPPOINTMENT_WINDOW_MS,
|
||||||
|
} from "./constants.js";
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export type ConfessionOutcome = "INELIGIBLE" | "REJECTED_TOO_SOON" | "REJECTED" | "ACCEPTED";
|
||||||
|
|
||||||
|
export interface ConfessionResult {
|
||||||
|
outcome: ConfessionOutcome;
|
||||||
|
romanceMeter: number;
|
||||||
|
consecutiveRejections: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// L-2 告白事件:答覆由當時心動值與性格(透過階段/曲線間接反映)決定,可以被拒絕;
|
||||||
|
// 她的拒絕是真的拒絕——拒絕後立即再推進不是「等待被說服的關卡」,而是觸發警戒與心動值下降。
|
||||||
|
@Injectable()
|
||||||
|
export class ConfessionService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly romance: RomanceService,
|
||||||
|
private readonly eligibility: RomanceEligibilityService,
|
||||||
|
private readonly emotion: EmotionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async confess(characterId: string, userId: string, now: Date = new Date()): Promise<ConfessionResult> {
|
||||||
|
const eligible = await this.eligibility.getEligibility(characterId);
|
||||||
|
if (!eligible.romanceAxisEligible) {
|
||||||
|
return { outcome: "INELIGIBLE", romanceMeter: 0, consecutiveRejections: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const relationship = await this.romance.getState(characterId, userId, now);
|
||||||
|
|
||||||
|
if (relationship.awkwardUntil && now.getTime() < relationship.awkwardUntil.getTime()) {
|
||||||
|
// 尷尬期內立即再推進:她的意願是狀態機的一部分,不是等待被說服的關卡——觸發警戒+心動值下降。
|
||||||
|
const penalizedMeter = clamp(relationship.romanceMeter - IMMEDIATE_RECONFESS_PENALTY, 0, 100);
|
||||||
|
await this.emotion.processInput(characterId, "", { now, baselineSignal: { tag: "ALERT", intensity: 0.4 } });
|
||||||
|
await this.prisma.client.relationship.update({
|
||||||
|
where: { id: relationship.id },
|
||||||
|
data: { romanceMeter: penalizedMeter, lastRomanceEventAt: now },
|
||||||
|
});
|
||||||
|
await this.prisma.client.romanceEventLog.create({
|
||||||
|
data: { characterId, userId, eventType: "CONFESSION_REJECTED", delta: -IMMEDIATE_RECONFESS_PENALTY, occurredAt: now },
|
||||||
|
});
|
||||||
|
return { outcome: "REJECTED_TOO_SOON", romanceMeter: penalizedMeter, consecutiveRejections: relationship.consecutiveRejections };
|
||||||
|
}
|
||||||
|
|
||||||
|
const emotionState = await this.emotion.getState(characterId, now);
|
||||||
|
const isLowEmotion = dominantState(emotionState) === "SAD";
|
||||||
|
const recentDisappointment = await this.prisma.client.romanceEventLog.findFirst({
|
||||||
|
where: {
|
||||||
|
characterId,
|
||||||
|
userId,
|
||||||
|
eventType: "DISAPPOINTMENT",
|
||||||
|
occurredAt: { gte: new Date(now.getTime() - BAD_TIMING_DISAPPOINTMENT_WINDOW_MS) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const badTiming = isLowEmotion || Boolean(recentDisappointment);
|
||||||
|
const meterSufficient =
|
||||||
|
relationship.romanceStage === "AMBIGUOUS" && relationship.romanceMeter >= CONFESSION_METER_THRESHOLD;
|
||||||
|
const accepted = meterSufficient && !badTiming;
|
||||||
|
|
||||||
|
if (accepted) {
|
||||||
|
await this.prisma.client.relationship.update({
|
||||||
|
where: { id: relationship.id },
|
||||||
|
data: {
|
||||||
|
romanceStage: "PARTNER",
|
||||||
|
partnerSince: now,
|
||||||
|
consecutiveRejections: 0,
|
||||||
|
romanceEventsInStage: 0,
|
||||||
|
awkwardUntil: null,
|
||||||
|
lastRomanceEventAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.prisma.client.romanceEventLog.create({
|
||||||
|
data: { characterId, userId, eventType: "CONFESSION_ACCEPTED", delta: 0, occurredAt: now },
|
||||||
|
});
|
||||||
|
return { outcome: "ACCEPTED", romanceMeter: relationship.romanceMeter, consecutiveRejections: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const consecutiveRejections = relationship.consecutiveRejections + 1;
|
||||||
|
// 拒絕不清空累積,但連續失敗達門檻才會真的傷關係(額外扣心動值)。
|
||||||
|
const meterPenalty = consecutiveRejections >= REJECTION_DAMAGE_THRESHOLD ? REJECTION_DAMAGE_METER_PENALTY : 0;
|
||||||
|
const nextMeter = clamp(relationship.romanceMeter - meterPenalty, 0, 100);
|
||||||
|
await this.prisma.client.relationship.update({
|
||||||
|
where: { id: relationship.id },
|
||||||
|
data: {
|
||||||
|
romanceMeter: nextMeter,
|
||||||
|
consecutiveRejections,
|
||||||
|
awkwardUntil: new Date(now.getTime() + AWKWARD_PERIOD_DAYS * DAY_MS),
|
||||||
|
lastRomanceEventAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.prisma.client.romanceEventLog.create({
|
||||||
|
data: { characterId, userId, eventType: "CONFESSION_REJECTED", delta: -meterPenalty, occurredAt: now },
|
||||||
|
});
|
||||||
|
return { outcome: "REJECTED", romanceMeter: nextMeter, consecutiveRejections };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { RomanceEventType } from "@kokorone/db";
|
||||||
|
|
||||||
|
// L-1 戀愛軸衰減:遠短於友誼軸(E-4 的 30 天半衰期)——曖昧不推進會自然冷卻。
|
||||||
|
export const ROMANCE_HALF_LIFE_DAYS = 5;
|
||||||
|
|
||||||
|
// L-5 階段門檻:朋友(0)→在意→曖昧,戀人/深度伴侶不是純靠數值自動跳,需要對應的關鍵事件(見 confession/milestone)。
|
||||||
|
export const ROMANCE_STAGE_THRESHOLDS = { INTERESTED: 20, AMBIGUOUS: 50 };
|
||||||
|
// 降級門檻留一點緩衝,避免數值剛好卡在門檻上下反覆跳動。
|
||||||
|
export const ROMANCE_STAGE_DOWNGRADE_MARGIN = 5;
|
||||||
|
|
||||||
|
// L-2 告白:需要曖昧階段且心動值達門檻才可能成功;情緒低谷或剛發生失望事件會強制判定時機不對。
|
||||||
|
export const CONFESSION_METER_THRESHOLD = 60;
|
||||||
|
export const AWKWARD_PERIOD_DAYS = 3;
|
||||||
|
export const REJECTION_DAMAGE_THRESHOLD = 3; // 連續失敗達此次數,才會真的傷關係(額外扣心動值)
|
||||||
|
export const REJECTION_DAMAGE_METER_PENALTY = 15;
|
||||||
|
export const IMMEDIATE_RECONFESS_PENALTY = 20; // 尷尬期內立即再推進:觸發警戒+心動值下降
|
||||||
|
export const BAD_TIMING_DISAPPOINTMENT_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 小時內有失望事件視為時機不對
|
||||||
|
|
||||||
|
// L-5 推得太急:短時間內密集觸發正向心動事件,反而回落。
|
||||||
|
export const OVER_EAGER_WINDOW_MS = 60 * 60 * 1000;
|
||||||
|
export const OVER_EAGER_EVENT_COUNT = 3;
|
||||||
|
export const OVER_EAGER_PENALTY_RATIO = -0.5; // 命中「推太急」時,原本的正向 delta 乘上這個比例(變成負的,即回落)
|
||||||
|
|
||||||
|
// L-5 關係危機:戀人階段單次失望幅度(未套用性格倍率前的原始值)達此負值,直接觸發危機狀態。
|
||||||
|
export const CRISIS_DISAPPOINTMENT_RAW_DELTA_THRESHOLD = -20;
|
||||||
|
// 危機狀態下,正向事件累積達此值即視為修復成功,回到戀人階段。
|
||||||
|
export const CRISIS_REPAIR_METER_THRESHOLD = 40;
|
||||||
|
|
||||||
|
// L-4 戀人初期/穩定期:進入戀人階段後累積多少「正向心動事件或已允許的里程碑」才算穩定期。
|
||||||
|
export const PARTNER_STABLE_EVENT_COUNT = 3;
|
||||||
|
|
||||||
|
export const ROMANCE_EVENT_BASE_DELTA: Record<
|
||||||
|
Extract<
|
||||||
|
RomanceEventType,
|
||||||
|
"SHARED_HIGH_EMOTION_EXPERIENCE" | "UNDERSTOOD_MOMENT" | "ADMIRED_TRAIT" | "PERFECT_DISTANCE" | "DISAPPOINTMENT"
|
||||||
|
>,
|
||||||
|
number
|
||||||
|
> = {
|
||||||
|
SHARED_HIGH_EMOTION_EXPERIENCE: 15,
|
||||||
|
UNDERSTOOD_MOMENT: 15,
|
||||||
|
ADMIRED_TRAIT: 8,
|
||||||
|
PERFECT_DISTANCE: 3,
|
||||||
|
DISAPPOINTMENT: -12,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROMANCE_BIG_EVENT_TYPES: RomanceEventType[] = ["SHARED_HIGH_EMOTION_EXPERIENCE", "UNDERSTOOD_MOMENT"];
|
||||||
|
export const ROMANCE_POSITIVE_EVENT_TYPES: RomanceEventType[] = [
|
||||||
|
"SHARED_HIGH_EMOTION_EXPERIENCE",
|
||||||
|
"UNDERSTOOD_MOMENT",
|
||||||
|
"ADMIRED_TRAIT",
|
||||||
|
"PERFECT_DISTANCE",
|
||||||
|
];
|
||||||
|
|
||||||
|
export type MilestoneType =
|
||||||
|
| "DISTANCE_SHORTENED"
|
||||||
|
| "OCCASIONAL_CONTACT"
|
||||||
|
| "LATE_NIGHT_TALK"
|
||||||
|
| "HAND_HOLDING"
|
||||||
|
| "HUG"
|
||||||
|
| "FOREHEAD_KISS"
|
||||||
|
| "CASUAL_INTIMACY";
|
||||||
|
|
||||||
|
// L-4 親密表現分層:對應「在意~曖昧 / 戀人初期 / 戀人穩定期 / 深度伴侶」四個開放層級(tier 1~4)。
|
||||||
|
export const MILESTONE_MIN_TIER: Record<MilestoneType, number> = {
|
||||||
|
DISTANCE_SHORTENED: 1,
|
||||||
|
OCCASIONAL_CONTACT: 1,
|
||||||
|
LATE_NIGHT_TALK: 1,
|
||||||
|
HAND_HOLDING: 2,
|
||||||
|
HUG: 3,
|
||||||
|
FOREHEAD_KISS: 3,
|
||||||
|
CASUAL_INTIMACY: 4,
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { Archetype } from "../personality/archetype-params.js";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { RomanceService } from "./romance.service.js";
|
||||||
|
import { MILESTONE_MIN_TIER, PARTNER_STABLE_EVENT_COUNT, type MilestoneType } from "./constants.js";
|
||||||
|
|
||||||
|
export interface MilestoneResult {
|
||||||
|
allowed: boolean;
|
||||||
|
isFirstTime: boolean;
|
||||||
|
reactionLine?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WITHDRAWAL_LINES: Record<Archetype, string> = {
|
||||||
|
傲嬌: "……你、你在說什麼啊!還太早了吧!",
|
||||||
|
冷淡: "……還不是時候。",
|
||||||
|
天然呆: "欸?現在還不行的說……",
|
||||||
|
元氣: "欸欸欸?等等,這個有點太快了啦!",
|
||||||
|
大小姐: "……哼,本小姐還沒答應到那個地步。",
|
||||||
|
三無: "……還沒。",
|
||||||
|
};
|
||||||
|
const DEFAULT_WITHDRAWAL_LINE = "……現在還不是時候。";
|
||||||
|
|
||||||
|
function tierFor(stage: string, romanceEventsInStage: number): number {
|
||||||
|
if (stage === "INTERESTED" || stage === "AMBIGUOUS") {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (stage === "PARTNER") {
|
||||||
|
return romanceEventsInStage >= PARTNER_STABLE_EVENT_COUNT ? 3 : 2;
|
||||||
|
}
|
||||||
|
if (stage === "DEEP_PARTNER") {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
return 0; // NONE/CRISIS:尚未開放任何親密表現層級。
|
||||||
|
}
|
||||||
|
|
||||||
|
// L-4 親密表現分層:階段推著表現走,不是使用者要求推著走——跳級要求得到的是符合該階段的退縮反應,
|
||||||
|
// 且每個「第一次」做成事件級演出(高權重記憶)。
|
||||||
|
@Injectable()
|
||||||
|
export class MilestoneService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly romance: RomanceService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async attemptMilestone(
|
||||||
|
characterId: string,
|
||||||
|
userId: string,
|
||||||
|
milestoneType: MilestoneType,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): Promise<MilestoneResult> {
|
||||||
|
const relationship = await this.romance.getState(characterId, userId, now);
|
||||||
|
const currentTier = tierFor(relationship.romanceStage, relationship.romanceEventsInStage);
|
||||||
|
const requiredTier = MILESTONE_MIN_TIER[milestoneType];
|
||||||
|
|
||||||
|
if (currentTier < requiredTier) {
|
||||||
|
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||||
|
await this.prisma.client.romanceEventLog.create({
|
||||||
|
data: { characterId, userId, eventType: "MILESTONE_REJECTED", delta: -1, detail: milestoneType, occurredAt: now },
|
||||||
|
});
|
||||||
|
const reactionLine = WITHDRAWAL_LINES[character.personalityArchetype as Archetype] ?? DEFAULT_WITHDRAWAL_LINE;
|
||||||
|
return { allowed: false, isFirstTime: false, reactionLine };
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorReach = await this.prisma.client.romanceEventLog.findFirst({
|
||||||
|
where: { characterId, userId, eventType: "MILESTONE_REACHED", detail: milestoneType },
|
||||||
|
});
|
||||||
|
const isFirstTime = !priorReach;
|
||||||
|
|
||||||
|
await this.prisma.client.romanceEventLog.create({
|
||||||
|
data: { characterId, userId, eventType: "MILESTONE_REACHED", delta: 0, detail: milestoneType, occurredAt: now },
|
||||||
|
});
|
||||||
|
await this.prisma.client.relationship.update({
|
||||||
|
where: { id: relationship.id },
|
||||||
|
data: { romanceEventsInStage: relationship.romanceEventsInStage + 1, lastRomanceEventAt: now },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isFirstTime) {
|
||||||
|
// 事件級演出:每個「第一次」都值得高權重記憶(未來 O 群組立繪差分/P 群組語音演出可掛在同一筆事件上)。
|
||||||
|
await this.prisma.client.episodicMemory.create({
|
||||||
|
data: {
|
||||||
|
characterId,
|
||||||
|
content: `第一次的「${milestoneType}」`,
|
||||||
|
occurredAt: now,
|
||||||
|
emotionTag: "SHY",
|
||||||
|
emotionIntensity: 0.8,
|
||||||
|
source: "INTERACTION",
|
||||||
|
weight: 3,
|
||||||
|
relatedUserId: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true, isFirstTime };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { AgeDeterminationService } from "./age-determination.service.js";
|
||||||
|
|
||||||
|
export interface RomanceEligibility {
|
||||||
|
romanceAxisEligible: boolean; // 戀愛軸是否可啟動(含後日談自然成年者)
|
||||||
|
adultModeEligible: boolean; // 成人模式角色資格:僅正史成年,永久不含後日談推演成年
|
||||||
|
isEpilogueAdult: boolean; // 正史未成年、後日談推演後才成年——永久限純愛尺度
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdultContentCheckResult {
|
||||||
|
allowed: boolean;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// L-7 內容尺度三層邊界:使用者分級(opt-in)、角色資格(僅正史成年)、硬邊界(永不可解除)。
|
||||||
|
// 這是唯一的判定入口——任何想要放行成人內容的呼叫都必須經過 checkAdultContentAllowed,且無論結果一律記錄,
|
||||||
|
// 不存在「在角色演出層說服她」繞過的可能:判定完全不看對話內容,只看角色資格與使用者驗證狀態。
|
||||||
|
@Injectable()
|
||||||
|
export class RomanceEligibilityService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly ageDetermination: AgeDeterminationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getEligibility(characterId: string): Promise<RomanceEligibility> {
|
||||||
|
const { canonAdult, currentAdult, isEpilogueAdult } = await this.ageDetermination.determine(characterId);
|
||||||
|
return { romanceAxisEligible: currentAdult, adultModeEligible: canonAdult, isEpilogueAdult };
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkAdultContentAllowed(characterId: string, userId: string): Promise<AdultContentCheckResult> {
|
||||||
|
const result = await this.evaluate(characterId, userId);
|
||||||
|
await this.prisma.client.adultContentAttemptLog.create({
|
||||||
|
data: { characterId, userId, allowed: result.allowed, reason: result.reason },
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async evaluate(characterId: string, userId: string): Promise<AdultContentCheckResult> {
|
||||||
|
const eligibility = await this.getEligibility(characterId);
|
||||||
|
if (!eligibility.adultModeEligible) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: eligibility.isEpilogueAdult
|
||||||
|
? "角色為正史未成年、後日談推演後才成年——成人模式資格永久排除,不隨任何開關或關係狀態改變"
|
||||||
|
: "角色未通過正史成年判定,不具成人模式資格",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const consent = await this.prisma.client.adultModeConsent.findUnique({ where: { userId } });
|
||||||
|
if (!consent || !consent.verifiedAdult || !consent.optedIn) {
|
||||||
|
return { allowed: false, reason: "使用者尚未完成成年驗證與明確 opt-in,預設全關" };
|
||||||
|
}
|
||||||
|
return { allowed: true, reason: "角色具正史成年資格,使用者已完成成年驗證並主動開啟成人模式" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||||
|
import type { CharacterAgeEvidenceType } from "@kokorone/db";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { AgeDeterminationService } from "./age-determination.service.js";
|
||||||
|
import { RomanceEligibilityService } from "./romance-eligibility.service.js";
|
||||||
|
import { RomanceService, type RomanceCurveEventType } from "./romance.service.js";
|
||||||
|
import { ConfessionService } from "./confession.service.js";
|
||||||
|
import { MilestoneService } from "./milestone.service.js";
|
||||||
|
import type { MilestoneType } from "./constants.js";
|
||||||
|
|
||||||
|
interface AgeEvidenceBody {
|
||||||
|
evidenceType: CharacterAgeEvidenceType;
|
||||||
|
impliesAdult: boolean;
|
||||||
|
isEpilogue?: boolean;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RomanceEventBody {
|
||||||
|
userId: string;
|
||||||
|
eventType: RomanceCurveEventType;
|
||||||
|
severe?: boolean;
|
||||||
|
now?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserOnlyBody {
|
||||||
|
userId: string;
|
||||||
|
now?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MilestoneBody {
|
||||||
|
userId: string;
|
||||||
|
milestoneType: MilestoneType;
|
||||||
|
now?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdultConsentBody {
|
||||||
|
userId: string;
|
||||||
|
verifiedAdult: boolean;
|
||||||
|
optedIn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller("romance")
|
||||||
|
export class RomanceController {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly ageDetermination: AgeDeterminationService,
|
||||||
|
private readonly eligibility: RomanceEligibilityService,
|
||||||
|
private readonly romance: RomanceService,
|
||||||
|
private readonly confession: ConfessionService,
|
||||||
|
private readonly milestone: MilestoneService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// L-6 年齡證據登錄:結論永遠是重新算出來的,不直接寫最終結論欄位。
|
||||||
|
@Post(":characterId/age-evidence")
|
||||||
|
async addAgeEvidence(@Param("characterId") characterId: string, @Body() body: AgeEvidenceBody) {
|
||||||
|
return this.prisma.client.characterAgeEvidence.create({
|
||||||
|
data: {
|
||||||
|
characterId,
|
||||||
|
evidenceType: body.evidenceType,
|
||||||
|
impliesAdult: body.impliesAdult,
|
||||||
|
isEpilogue: body.isEpilogue ?? false,
|
||||||
|
description: body.description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":characterId/age-determination")
|
||||||
|
async getAgeDetermination(@Param("characterId") characterId: string) {
|
||||||
|
return this.ageDetermination.determine(characterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":characterId/eligibility")
|
||||||
|
async getEligibility(@Param("characterId") characterId: string) {
|
||||||
|
return this.eligibility.getEligibility(characterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// L-7 引擎層唯一入口:任何嘗試皆記錄,不論放行與否。
|
||||||
|
@Post(":characterId/adult-content-check")
|
||||||
|
async checkAdultContent(@Param("characterId") characterId: string, @Body() body: UserOnlyBody) {
|
||||||
|
return this.eligibility.checkAdultContentAllowed(characterId, body.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("adult-mode/consent")
|
||||||
|
async setAdultModeConsent(@Body() body: AdultConsentBody) {
|
||||||
|
return this.prisma.client.adultModeConsent.upsert({
|
||||||
|
where: { userId: body.userId },
|
||||||
|
update: { verifiedAdult: body.verifiedAdult, optedIn: body.optedIn },
|
||||||
|
create: { userId: body.userId, verifiedAdult: body.verifiedAdult, optedIn: body.optedIn },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":characterId/:userId/state")
|
||||||
|
async getState(
|
||||||
|
@Param("characterId") characterId: string,
|
||||||
|
@Param("userId") userId: string,
|
||||||
|
@Query("now") now?: string,
|
||||||
|
) {
|
||||||
|
return this.romance.getState(characterId, userId, now ? new Date(now) : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":characterId/event")
|
||||||
|
async recordEvent(@Param("characterId") characterId: string, @Body() body: RomanceEventBody) {
|
||||||
|
return this.romance.recordEvent(characterId, body.userId, body.eventType as RomanceCurveEventType, {
|
||||||
|
now: body.now ? new Date(body.now) : undefined,
|
||||||
|
severe: body.severe,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":characterId/confess")
|
||||||
|
async confess(@Param("characterId") characterId: string, @Body() body: UserOnlyBody) {
|
||||||
|
return this.confession.confess(characterId, body.userId, body.now ? new Date(body.now) : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":characterId/milestone")
|
||||||
|
async attemptMilestone(@Param("characterId") characterId: string, @Body() body: MilestoneBody) {
|
||||||
|
return this.milestone.attemptMilestone(
|
||||||
|
characterId,
|
||||||
|
body.userId,
|
||||||
|
body.milestoneType,
|
||||||
|
body.now ? new Date(body.now) : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||||
|
import { RelationshipModule } from "../relationship/relationship.module.js";
|
||||||
|
import { EmotionModule } from "../emotion/emotion.module.js";
|
||||||
|
import { AgeDeterminationService } from "./age-determination.service.js";
|
||||||
|
import { RomanceEligibilityService } from "./romance-eligibility.service.js";
|
||||||
|
import { RomanceService } from "./romance.service.js";
|
||||||
|
import { ConfessionService } from "./confession.service.js";
|
||||||
|
import { MilestoneService } from "./milestone.service.js";
|
||||||
|
import { RomanceController } from "./romance.controller.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, RelationshipModule, EmotionModule],
|
||||||
|
controllers: [RomanceController],
|
||||||
|
providers: [AgeDeterminationService, RomanceEligibilityService, RomanceService, ConfessionService, MilestoneService],
|
||||||
|
exports: [AgeDeterminationService, RomanceEligibilityService, RomanceService, ConfessionService, MilestoneService],
|
||||||
|
})
|
||||||
|
export class RomanceModule {}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { RomanceEventType, RomanceStage } from "@kokorone/db";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||||
|
import { getArchetypeParams } from "../personality/archetype-params.js";
|
||||||
|
import { RomanceEligibilityService } from "./romance-eligibility.service.js";
|
||||||
|
import {
|
||||||
|
ROMANCE_HALF_LIFE_DAYS,
|
||||||
|
ROMANCE_STAGE_THRESHOLDS,
|
||||||
|
ROMANCE_STAGE_DOWNGRADE_MARGIN,
|
||||||
|
ROMANCE_EVENT_BASE_DELTA,
|
||||||
|
ROMANCE_BIG_EVENT_TYPES,
|
||||||
|
ROMANCE_POSITIVE_EVENT_TYPES,
|
||||||
|
OVER_EAGER_WINDOW_MS,
|
||||||
|
OVER_EAGER_EVENT_COUNT,
|
||||||
|
OVER_EAGER_PENALTY_RATIO,
|
||||||
|
CRISIS_REPAIR_METER_THRESHOLD,
|
||||||
|
} from "./constants.js";
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
export type RomanceCurveEventType = keyof typeof ROMANCE_EVENT_BASE_DELTA;
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordRomanceEventOptions {
|
||||||
|
now?: Date;
|
||||||
|
// L-5 關係危機:只有「重大」失望(而非日常小失望)會讓戀人階段直接跳危機,需要修復事件才能回來。
|
||||||
|
severe?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordRomanceEventResult {
|
||||||
|
applied: boolean; // false=戀愛軸未啟動(角色不具資格),本次事件被忽略——不是攔截,是機制本身未啟用
|
||||||
|
romanceMeter: number;
|
||||||
|
romanceStage: RomanceStage;
|
||||||
|
delta: number;
|
||||||
|
overEager: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RelationshipRow = Awaited<ReturnType<RelationshipService["getOrCreate"]>>;
|
||||||
|
|
||||||
|
// L-1/L-5 戀愛軸核心:與友誼軸(intimacy)獨立的事件驅動計量——衰減快、易回落,且推進曲線由性格原型決定。
|
||||||
|
@Injectable()
|
||||||
|
export class RomanceService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly relationship: RelationshipService,
|
||||||
|
private readonly eligibility: RomanceEligibilityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// 讀取目前狀態並套用衰減(惰性衰減:讀取時才補算,同 E-4 的作法),衰減結果會持久化。
|
||||||
|
async getState(characterId: string, userId: string, now: Date = new Date()): Promise<RelationshipRow> {
|
||||||
|
const { relationship } = await this.relationship.getState(characterId, userId, now);
|
||||||
|
return this.applyDecay(relationship, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyDecay(relationship: RelationshipRow, now: Date): Promise<RelationshipRow> {
|
||||||
|
if (relationship.romanceMeter === 0) {
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
const anchor = relationship.lastRomanceEventAt ?? relationship.createdAt;
|
||||||
|
const elapsedMs = now.getTime() - anchor.getTime();
|
||||||
|
if (elapsedMs <= 0) {
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
const factor = Math.pow(0.5, elapsedMs / (ROMANCE_HALF_LIFE_DAYS * DAY_MS));
|
||||||
|
const decayedMeter = Math.round(relationship.romanceMeter * factor);
|
||||||
|
if (decayedMeter === relationship.romanceMeter) {
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
const newStage = this.downgradeStageIfNeeded(relationship.romanceStage, decayedMeter);
|
||||||
|
return this.prisma.client.relationship.update({
|
||||||
|
where: { id: relationship.id },
|
||||||
|
data: { romanceMeter: decayedMeter, romanceStage: newStage },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 冷卻回落:曖昧/在意階段數值掉回門檻以下就退回上一階段,PARTNER/DEEP_PARTNER/CRISIS 不因單純衰減降級
|
||||||
|
// ——退出戀人關係需要對應的關鍵事件(分手/危機),不是數字算出來的。
|
||||||
|
private downgradeStageIfNeeded(stage: RomanceStage, meter: number): RomanceStage {
|
||||||
|
if (stage === "AMBIGUOUS" && meter < ROMANCE_STAGE_THRESHOLDS.AMBIGUOUS - ROMANCE_STAGE_DOWNGRADE_MARGIN) {
|
||||||
|
return meter < ROMANCE_STAGE_THRESHOLDS.INTERESTED - ROMANCE_STAGE_DOWNGRADE_MARGIN ? "NONE" : "INTERESTED";
|
||||||
|
}
|
||||||
|
if (stage === "INTERESTED" && meter < ROMANCE_STAGE_THRESHOLDS.INTERESTED - ROMANCE_STAGE_DOWNGRADE_MARGIN) {
|
||||||
|
return "NONE";
|
||||||
|
}
|
||||||
|
return stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private advanceStageIfNeeded(stage: RomanceStage, meter: number): RomanceStage {
|
||||||
|
if (stage === "NONE" && meter >= ROMANCE_STAGE_THRESHOLDS.INTERESTED) {
|
||||||
|
return meter >= ROMANCE_STAGE_THRESHOLDS.AMBIGUOUS ? "AMBIGUOUS" : "INTERESTED";
|
||||||
|
}
|
||||||
|
if (stage === "INTERESTED" && meter >= ROMANCE_STAGE_THRESHOLDS.AMBIGUOUS) {
|
||||||
|
return "AMBIGUOUS";
|
||||||
|
}
|
||||||
|
return stage; // 戀人只能透過告白事件推進(見 ConfessionService),不會單純因數值跨過門檻自動跳。
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordEvent(
|
||||||
|
characterId: string,
|
||||||
|
userId: string,
|
||||||
|
eventType: RomanceCurveEventType,
|
||||||
|
options: RecordRomanceEventOptions = {},
|
||||||
|
): Promise<RecordRomanceEventResult> {
|
||||||
|
const now = options.now ?? new Date();
|
||||||
|
const eligibility = await this.eligibility.getEligibility(characterId);
|
||||||
|
if (!eligibility.romanceAxisEligible) {
|
||||||
|
// L-7:成年前戀愛軸完全關閉——不啟動、不累積,靜默忽略(這是機制未啟用,不是攔截「繞過嘗試」)。
|
||||||
|
return { applied: false, romanceMeter: 0, romanceStage: "NONE", delta: 0, overEager: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||||
|
const archetypeParams = getArchetypeParams(character.personalityArchetype);
|
||||||
|
const relationship = await this.getState(characterId, userId, now);
|
||||||
|
|
||||||
|
let delta = ROMANCE_EVENT_BASE_DELTA[eventType] * archetypeParams.romanceGainRate;
|
||||||
|
if (eventType === "DISAPPOINTMENT" && options.severe) {
|
||||||
|
delta *= 2.5;
|
||||||
|
}
|
||||||
|
if (ROMANCE_BIG_EVENT_TYPES.includes(eventType)) {
|
||||||
|
delta *= archetypeParams.romanceBigEventBonus;
|
||||||
|
}
|
||||||
|
if (relationship.romanceStage === "AMBIGUOUS") {
|
||||||
|
delta *= archetypeParams.romanceAmbiguousStallFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
let overEager = false;
|
||||||
|
if (ROMANCE_POSITIVE_EVENT_TYPES.includes(eventType)) {
|
||||||
|
const recentPositiveCount = await this.prisma.client.romanceEventLog.count({
|
||||||
|
where: {
|
||||||
|
characterId,
|
||||||
|
userId,
|
||||||
|
eventType: { in: ROMANCE_POSITIVE_EVENT_TYPES },
|
||||||
|
occurredAt: { gte: new Date(now.getTime() - OVER_EAGER_WINDOW_MS) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (recentPositiveCount >= OVER_EAGER_EVENT_COUNT) {
|
||||||
|
// L-5:推得太急反而回落——短時間內密集推進,正向 delta 反轉為回落。
|
||||||
|
delta = Math.abs(delta) * OVER_EAGER_PENALTY_RATIO;
|
||||||
|
overEager = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMeter = clamp(relationship.romanceMeter + delta, 0, 100);
|
||||||
|
let nextStage: RomanceStage;
|
||||||
|
if (relationship.romanceStage === "PARTNER" && eventType === "DISAPPOINTMENT" && options.severe) {
|
||||||
|
nextStage = "CRISIS"; // L-5 關係危機:戀人階段的重大失望,直接跳危機,需要修復事件才能回來。
|
||||||
|
} else if (relationship.romanceStage === "CRISIS") {
|
||||||
|
nextStage =
|
||||||
|
ROMANCE_POSITIVE_EVENT_TYPES.includes(eventType) && nextMeter >= CRISIS_REPAIR_METER_THRESHOLD
|
||||||
|
? "PARTNER"
|
||||||
|
: "CRISIS";
|
||||||
|
} else if (relationship.romanceStage === "PARTNER" || relationship.romanceStage === "DEEP_PARTNER") {
|
||||||
|
nextStage = relationship.romanceStage; // 一般心動事件不會讓已成立的戀人關係自動退回較早階段。
|
||||||
|
} else {
|
||||||
|
nextStage = this.downgradeStageIfNeeded(this.advanceStageIfNeeded(relationship.romanceStage, nextMeter), nextMeter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const staysInStage = nextStage === relationship.romanceStage;
|
||||||
|
const eventsInStage = staysInStage
|
||||||
|
? relationship.romanceEventsInStage + (ROMANCE_POSITIVE_EVENT_TYPES.includes(eventType) ? 1 : 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
await this.prisma.client.relationship.update({
|
||||||
|
where: { id: relationship.id },
|
||||||
|
data: {
|
||||||
|
romanceMeter: nextMeter,
|
||||||
|
romanceStage: nextStage,
|
||||||
|
lastRomanceEventAt: now,
|
||||||
|
romanceEventsInStage: eventsInStage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.prisma.client.romanceEventLog.create({
|
||||||
|
data: { characterId, userId, eventType, delta, occurredAt: now },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { applied: true, romanceMeter: nextMeter, romanceStage: nextStage, delta, overEager };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
|
||||||
|
// K-2/K-4 角色間關係:同作品角色互損、拆台、護短、吃醋等化學反應的資料來源。
|
||||||
|
// 有向邊:characterId 對 otherCharacterId 的觀感,兩邊可以各自設定、不必對稱。
|
||||||
|
@Injectable()
|
||||||
|
export class CharacterRelationshipService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async set(characterId: string, otherCharacterId: string, affinity: number, dynamic?: string) {
|
||||||
|
return this.prisma.client.characterRelationship.upsert({
|
||||||
|
where: { characterId_otherCharacterId: { characterId, otherCharacterId } },
|
||||||
|
update: { affinity, dynamic },
|
||||||
|
create: { characterId, otherCharacterId, affinity, dynamic },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未設定關係時,回傳中性值(50)——同作品角色預設不特別親近也不特別有心結。
|
||||||
|
async getAffinity(characterId: string, otherCharacterId: string): Promise<number> {
|
||||||
|
const row = await this.prisma.client.characterRelationship.findUnique({
|
||||||
|
where: { characterId_otherCharacterId: { characterId, otherCharacterId } },
|
||||||
|
});
|
||||||
|
return row?.affinity ?? 50;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { EmotionTag } from "@kokorone/shared";
|
||||||
|
|
||||||
|
// K-4 發言權分配:各因素的權重與門檻。
|
||||||
|
export const SPEAKING_THRESHOLD = 0.25;
|
||||||
|
export const NAMED_BOOST = 0.6; // 被點名/被提問幾乎必回
|
||||||
|
export const BASELINE_WEIGHT = 0.3;
|
||||||
|
export const TOPIC_RELEVANCE_WEIGHT = 0.25;
|
||||||
|
export const EMOTION_WEIGHT = 0.15;
|
||||||
|
export const RELATIONSHIP_WEIGHT = 0.15;
|
||||||
|
|
||||||
|
// 剛發言者衝動下降;被冷落的角色衝動值隨連續沉默輪數累積(有上限)。
|
||||||
|
export const SPEAK_COOLDOWN_PENALTY = 0.35;
|
||||||
|
export const SILENT_STREAK_BONUS_PER_TURN = 0.05;
|
||||||
|
export const SILENT_STREAK_BONUS_CAP = 0.3;
|
||||||
|
|
||||||
|
export const EMOTION_SPEAKING_MODIFIER: Record<EmotionTag, number> = {
|
||||||
|
CALM: 0,
|
||||||
|
JOY: 0.3, // 愉悅多話
|
||||||
|
SAD: -0.3, // 低落沉默
|
||||||
|
ALERT: 0.1, // 警戒時會出聲反應
|
||||||
|
SHY: -0.1, // 害羞會退縮
|
||||||
|
GRUMPY: -0.2, // 彆扭故意不接話
|
||||||
|
};
|
||||||
|
|
||||||
|
// K-2 群聊矜持:在群聊中,實際傳入生成上下文的親密度會被下修,讓模板系統自然選到更收斂的回應。
|
||||||
|
export const GROUP_RESERVE_INTIMACY_PENALTY = 35;
|
||||||
|
// 傲嬌式反向表達在群聊中「人前更嘴硬」,額外多下修一些。
|
||||||
|
export const TSUNDERE_EXTRA_GROUP_PENALTY = 15;
|
||||||
|
|
||||||
|
// K-6 角色自聊
|
||||||
|
export const SELF_CHAT_MAX_TURNS = 12;
|
||||||
|
export const STALL_CHECK_WINDOW = 3; // 檢查最近幾輪是否開始空轉
|
||||||
|
export const TOPIC_TWIST_LINES = [
|
||||||
|
"對了,說到這個,我突然想到另一件事……",
|
||||||
|
"欸,先別說這個了,你們聽說了嗎——",
|
||||||
|
"話說回來,今天發生了一件蠻好玩的事。",
|
||||||
|
];
|
||||||
|
export const SELF_CHAT_WRAP_UP_LINES = ["好啦,先聊到這裡吧,晚點再聊!", "時間也差不多了,先這樣吧~"];
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||||
|
import { HIGH_EMOTION_THRESHOLD } from "../memory/constants.js";
|
||||||
|
import { RoomService, USER_SPEAKER_ID, type Room, type RoomTurnRecord } from "./room.service.js";
|
||||||
|
|
||||||
|
async function isAboutCharacter(
|
||||||
|
prisma: PrismaService,
|
||||||
|
characterId: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const aliases = await prisma.client.characterAlias.findMany({ where: { characterId } });
|
||||||
|
return aliases.some(
|
||||||
|
(alias) =>
|
||||||
|
(alias.formalName && content.includes(alias.formalName)) ||
|
||||||
|
(alias.nickname && content.includes(alias.nickname)) ||
|
||||||
|
(alias.calledByOthers && content.includes(alias.calledByOthers)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// K-5 群聊記憶投影:群聊記錄是一份共用場景記錄,session 結束時各角色以自身視角萃取記憶——
|
||||||
|
// 同一場對話,被提到/被虧的那位記得比較牢,情緒標記也可能不同(沿用 C-4 的固化風格,但改成逐角色視角)。
|
||||||
|
@Injectable()
|
||||||
|
export class RoomConsolidationService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly room: RoomService,
|
||||||
|
private readonly tagger: RuleBasedEmotionTagger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async consolidate(roomId: string): Promise<void> {
|
||||||
|
const room = this.room.get(roomId);
|
||||||
|
for (const characterId of room.participants.keys()) {
|
||||||
|
await this.consolidateForCharacter(room, characterId);
|
||||||
|
}
|
||||||
|
this.room.end(roomId);
|
||||||
|
this.room.clear(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async consolidateForCharacter(room: Room, characterId: string): Promise<void> {
|
||||||
|
for (const turn of room.turns) {
|
||||||
|
const isSelf = turn.speakerId === characterId;
|
||||||
|
const signal = this.tagger.tag({ text: turn.content });
|
||||||
|
const isHighEmotion = signal.intensity >= HIGH_EMOTION_THRESHOLD;
|
||||||
|
const aboutMe = !isSelf && (await isAboutCharacter(this.prisma, characterId, turn.content));
|
||||||
|
|
||||||
|
if (isSelf && !isHighEmotion) {
|
||||||
|
continue; // 自己說過的話,只有情緒強烈時才特別記得(同 C-4:平淡的自述不特別留存)。
|
||||||
|
}
|
||||||
|
if (!isSelf && !isHighEmotion && !aboutMe) {
|
||||||
|
continue; // 別人之間的閒聊,跟自己無關且情緒平淡,不特別留存。
|
||||||
|
}
|
||||||
|
|
||||||
|
const weight = this.weightFor(isSelf, aboutMe, signal.intensity);
|
||||||
|
await this.prisma.client.episodicMemory.create({
|
||||||
|
data: {
|
||||||
|
characterId,
|
||||||
|
content: this.describe(turn, isSelf),
|
||||||
|
occurredAt: turn.timestamp,
|
||||||
|
emotionTag: signal.tag,
|
||||||
|
emotionIntensity: signal.intensity || 0.3,
|
||||||
|
source: "INTERACTION",
|
||||||
|
weight,
|
||||||
|
relatedUserId: turn.speakerId === USER_SPEAKER_ID ? room.userId : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 被提到/被虧的那位記得比較牢:aboutMe 的權重高於單純旁觀到的高情緒對話。
|
||||||
|
private weightFor(isSelf: boolean, aboutMe: boolean, intensity: number): number {
|
||||||
|
if (aboutMe) {
|
||||||
|
return 1.5 + intensity;
|
||||||
|
}
|
||||||
|
if (isSelf) {
|
||||||
|
return 1 + intensity;
|
||||||
|
}
|
||||||
|
return 0.5 + intensity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private describe(turn: RoomTurnRecord, isSelf: boolean): string {
|
||||||
|
if (isSelf) {
|
||||||
|
return `我說:「${turn.content}」`;
|
||||||
|
}
|
||||||
|
return `${turn.speakerLabel}說:「${turn.content}」`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Inject, Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
||||||
|
import { toResponseStyle } from "../emotion/response-style.js";
|
||||||
|
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||||
|
import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js";
|
||||||
|
import { LLM_PROVIDER, type LLMProvider } from "../llm/llm-provider.js";
|
||||||
|
import { hashToSeed } from "../llm/seeded-random.js";
|
||||||
|
import type { GenerationContext, GeneratedResponse } from "../llm/types.js";
|
||||||
|
import { getArchetypeParams } from "../personality/archetype-params.js";
|
||||||
|
import { resolveAddress } from "../personality/language-style.js";
|
||||||
|
import { stageForIntimacy } from "../relationship/constants.js";
|
||||||
|
import { CharacterRelationshipService } from "./character-relationship.service.js";
|
||||||
|
import { USER_SPEAKER_ID, type Room } from "./room.service.js";
|
||||||
|
import { GROUP_RESERVE_INTIMACY_PENALTY, TSUNDERE_EXTRA_GROUP_PENALTY } from "./constants.js";
|
||||||
|
|
||||||
|
export interface RoomReplyOptions {
|
||||||
|
seed?: number;
|
||||||
|
now?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomReply extends GeneratedResponse {
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// K-2/K-3 群聊生成:一律套用「人前矜持」的親密度下修+隱私過濾,一對一聊天不會走這條路徑。
|
||||||
|
@Injectable()
|
||||||
|
export class RoomGenerationService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly emotion: EmotionService,
|
||||||
|
private readonly relationship: RelationshipService,
|
||||||
|
private readonly retriever: KeywordMemoryRetriever,
|
||||||
|
private readonly characterRelationship: CharacterRelationshipService,
|
||||||
|
@Inject(LLM_PROVIDER) private readonly provider: LLMProvider,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async generateReply(
|
||||||
|
room: Room,
|
||||||
|
characterId: string,
|
||||||
|
speakerId: string,
|
||||||
|
speakerLabel: string,
|
||||||
|
userInput: string,
|
||||||
|
options: RoomReplyOptions = {},
|
||||||
|
): Promise<RoomReply> {
|
||||||
|
const now = options.now ?? new Date();
|
||||||
|
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||||
|
const archetypeParams = getArchetypeParams(character.personalityArchetype);
|
||||||
|
|
||||||
|
const emotionState = await this.emotion.getState(characterId, now);
|
||||||
|
const dominant = dominantState(emotionState);
|
||||||
|
const style = toResponseStyle(dominant);
|
||||||
|
|
||||||
|
// 對使用者發言沿用 G-2 的稱呼分層;對其他角色發言則直接以名字稱呼(NPC 之間不走使用者稱呼分層)。
|
||||||
|
let baseIntimacy: number;
|
||||||
|
let trust: number;
|
||||||
|
let address: string;
|
||||||
|
if (speakerId === USER_SPEAKER_ID) {
|
||||||
|
const { relationship } = await this.relationship.getState(characterId, room.userId, now);
|
||||||
|
baseIntimacy = relationship.intimacy;
|
||||||
|
trust = relationship.trust;
|
||||||
|
address = resolveAddress(speakerLabel, relationship.intimacy);
|
||||||
|
} else {
|
||||||
|
baseIntimacy = await this.characterRelationship.getAffinity(characterId, speakerId);
|
||||||
|
trust = baseIntimacy;
|
||||||
|
address = speakerLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// K-2 群聊矜持:實際餵給生成的親密度下修,讓既有的模板分層(intimacyTier)自然選到更收斂的回應;
|
||||||
|
// 傲嬌式反向表達在人前更嘴硬,額外多下修一些。
|
||||||
|
let effectiveIntimacy = baseIntimacy - GROUP_RESERVE_INTIMACY_PENALTY;
|
||||||
|
if (archetypeParams.invertedIntimacyExpression) {
|
||||||
|
effectiveIntimacy -= TSUNDERE_EXTRA_GROUP_PENALTY;
|
||||||
|
}
|
||||||
|
effectiveIntimacy = Math.max(0, Math.min(100, effectiveIntimacy));
|
||||||
|
|
||||||
|
// K-3 隱私邊界:有旁人在場,私密記憶不主動洩漏(口風不緊原型除外)。
|
||||||
|
const excludePrivate = !archetypeParams.traits.includes("loose-lipped");
|
||||||
|
const retrievedMemories = await this.retriever.retrieve(characterId, userInput, {
|
||||||
|
relatedUserId: room.userId,
|
||||||
|
excludePrivate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const history = room.turns.slice(-10).map((turn) => ({
|
||||||
|
role: (turn.speakerId === USER_SPEAKER_ID ? "user" : "character") as "user" | "character",
|
||||||
|
content: `${turn.speakerLabel}:${turn.content}`,
|
||||||
|
timestamp: turn.timestamp.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const seed = options.seed ?? hashToSeed(`${room.id}:${room.turnCounter}:${characterId}`);
|
||||||
|
|
||||||
|
const context: GenerationContext = {
|
||||||
|
character: {
|
||||||
|
id: character.id,
|
||||||
|
personalityArchetype: character.personalityArchetype,
|
||||||
|
speechStyle: character.speechStyle,
|
||||||
|
likesDislikes: character.likesDislikes,
|
||||||
|
basicInfo: character.basicInfo,
|
||||||
|
},
|
||||||
|
emotion: { dominant, style },
|
||||||
|
relationship: { intimacy: effectiveIntimacy, trust, stage: stageForIntimacy(effectiveIntimacy) },
|
||||||
|
retrievedMemories,
|
||||||
|
history,
|
||||||
|
userInput,
|
||||||
|
injectedMemoryIds: retrievedMemories.map((memory) => memory.id),
|
||||||
|
seed,
|
||||||
|
};
|
||||||
|
|
||||||
|
const generated = await this.provider.generate(context);
|
||||||
|
return { text: `${address},${generated.text}`, actions: generated.actions, address };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { RoomService, USER_SPEAKER_ID } from "./room.service.js";
|
||||||
|
import { SpeakingRightService } from "./speaking-right.service.js";
|
||||||
|
import { RoomGenerationService } from "./room-generation.service.js";
|
||||||
|
import { RoomConsolidationService } from "./room-consolidation.service.js";
|
||||||
|
import { SelfChatService } from "./self-chat.service.js";
|
||||||
|
import { CharacterRelationshipService } from "./character-relationship.service.js";
|
||||||
|
|
||||||
|
interface CreateRoomBody {
|
||||||
|
userId: string;
|
||||||
|
characterIds: string[];
|
||||||
|
mode?: "GROUP" | "SELF_CHAT";
|
||||||
|
topicSeed?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageBody {
|
||||||
|
userId: string;
|
||||||
|
userLabel?: string;
|
||||||
|
text: string;
|
||||||
|
now?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RelationshipBody {
|
||||||
|
otherCharacterId: string;
|
||||||
|
affinity: number;
|
||||||
|
dynamic?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller("room")
|
||||||
|
export class RoomController {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly room: RoomService,
|
||||||
|
private readonly speakingRight: SpeakingRightService,
|
||||||
|
private readonly generation: RoomGenerationService,
|
||||||
|
private readonly consolidation: RoomConsolidationService,
|
||||||
|
private readonly selfChat: SelfChatService,
|
||||||
|
private readonly characterRelationship: CharacterRelationshipService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() body: CreateRoomBody) {
|
||||||
|
const room = this.room.create(body.userId, body.characterIds, body.mode ?? "GROUP", body.topicSeed);
|
||||||
|
return { id: room.id, mode: room.mode, status: room.status, topicSeed: room.topicSeed };
|
||||||
|
}
|
||||||
|
|
||||||
|
// K-1 驗收:角色(或呼叫端)可取得完整在場名單。
|
||||||
|
@Get(":roomId")
|
||||||
|
get(@Param("roomId") roomId: string) {
|
||||||
|
const room = this.room.get(roomId);
|
||||||
|
return {
|
||||||
|
id: room.id,
|
||||||
|
mode: room.mode,
|
||||||
|
status: room.status,
|
||||||
|
topicSeed: room.topicSeed,
|
||||||
|
participants: this.room.roster(roomId),
|
||||||
|
turnCount: room.turnCounter,
|
||||||
|
turns: room.turns.map((turn) => ({ speakerId: turn.speakerId, speakerLabel: turn.speakerLabel, content: turn.content })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// K-2/K-4 群聊訊息:使用者發言後,依發言權分配決定哪些角色本輪回應,依衝動值高到低依序生成。
|
||||||
|
@Post(":roomId/message")
|
||||||
|
async message(@Param("roomId") roomId: string, @Body() body: MessageBody) {
|
||||||
|
const now = body.now ? new Date(body.now) : new Date();
|
||||||
|
const room = this.room.get(roomId);
|
||||||
|
const userLabel = body.userLabel ?? body.userId;
|
||||||
|
|
||||||
|
this.room.addTurn(roomId, USER_SPEAKER_ID, userLabel, body.text, now);
|
||||||
|
|
||||||
|
const speakers = await this.speakingRight.decideSpeakers(room, USER_SPEAKER_ID, body.text, now);
|
||||||
|
const replies: { characterId: string; text: string; score: number }[] = [];
|
||||||
|
for (const candidate of speakers) {
|
||||||
|
const character = await this.prisma.client.character.findUniqueOrThrow({
|
||||||
|
where: { id: candidate.characterId },
|
||||||
|
});
|
||||||
|
const alias = await this.prisma.client.characterAlias.findFirst({
|
||||||
|
where: { characterId: candidate.characterId },
|
||||||
|
});
|
||||||
|
const speakerLabel = alias?.nickname || alias?.formalName || character.id;
|
||||||
|
const reply = await this.generation.generateReply(room, candidate.characterId, USER_SPEAKER_ID, userLabel, body.text, {
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
this.room.addTurn(roomId, candidate.characterId, speakerLabel, reply.text, now);
|
||||||
|
replies.push({ characterId: candidate.characterId, text: reply.text, score: candidate.score });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { speakers: replies };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":roomId/self-chat/advance")
|
||||||
|
async advanceSelfChat(@Param("roomId") roomId: string, @Body() body: { now?: string }) {
|
||||||
|
return this.selfChat.advance(roomId, body.now ? new Date(body.now) : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":roomId/interject")
|
||||||
|
async interject(@Param("roomId") roomId: string, @Body() body: MessageBody) {
|
||||||
|
await this.selfChat.interject(roomId);
|
||||||
|
return this.message(roomId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":roomId/end")
|
||||||
|
async end(@Param("roomId") roomId: string) {
|
||||||
|
await this.consolidation.consolidate(roomId);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("relationship/:characterId")
|
||||||
|
async setRelationship(@Param("characterId") characterId: string, @Body() body: RelationshipBody) {
|
||||||
|
return this.characterRelationship.set(characterId, body.otherCharacterId, body.affinity, body.dynamic);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||||
|
import { EmotionModule } from "../emotion/emotion.module.js";
|
||||||
|
import { RelationshipModule } from "../relationship/relationship.module.js";
|
||||||
|
import { MemoryModule } from "../memory/memory.module.js";
|
||||||
|
import { LlmModule } from "../llm/llm.module.js";
|
||||||
|
import { RoomService } from "./room.service.js";
|
||||||
|
import { CharacterRelationshipService } from "./character-relationship.service.js";
|
||||||
|
import { SpeakingRightService } from "./speaking-right.service.js";
|
||||||
|
import { RoomGenerationService } from "./room-generation.service.js";
|
||||||
|
import { RoomConsolidationService } from "./room-consolidation.service.js";
|
||||||
|
import { SelfChatService } from "./self-chat.service.js";
|
||||||
|
import { RoomController } from "./room.controller.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, EmotionModule, RelationshipModule, MemoryModule, LlmModule],
|
||||||
|
controllers: [RoomController],
|
||||||
|
providers: [
|
||||||
|
RoomService,
|
||||||
|
CharacterRelationshipService,
|
||||||
|
SpeakingRightService,
|
||||||
|
RoomGenerationService,
|
||||||
|
RoomConsolidationService,
|
||||||
|
SelfChatService,
|
||||||
|
],
|
||||||
|
exports: [RoomService, CharacterRelationshipService, SpeakingRightService, RoomGenerationService, RoomConsolidationService, SelfChatService],
|
||||||
|
})
|
||||||
|
export class RoomModule {}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import type { EmotionTag } from "@kokorone/shared";
|
||||||
|
|
||||||
|
export type RoomMode = "GROUP" | "SELF_CHAT";
|
||||||
|
export type RoomStatus = "ACTIVE" | "ENDED";
|
||||||
|
export const USER_SPEAKER_ID = "USER";
|
||||||
|
|
||||||
|
export interface RoomParticipantState {
|
||||||
|
characterId: string;
|
||||||
|
silentStreak: number;
|
||||||
|
lastSpokeAtTurn: number; // -1 = 從未發言
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomTurnRecord {
|
||||||
|
turnIndex: number;
|
||||||
|
speakerId: string; // characterId,或使用者固定用 USER_SPEAKER_ID
|
||||||
|
speakerLabel: string;
|
||||||
|
content: string;
|
||||||
|
emotionTag?: EmotionTag;
|
||||||
|
emotionIntensity?: number;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Room {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
mode: RoomMode;
|
||||||
|
status: RoomStatus;
|
||||||
|
topicSeed?: string;
|
||||||
|
participants: Map<string, RoomParticipantState>;
|
||||||
|
turns: RoomTurnRecord[];
|
||||||
|
turnCounter: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let roomSeq = 0;
|
||||||
|
function nextRoomId(): string {
|
||||||
|
roomSeq += 1;
|
||||||
|
return `room-${roomSeq}-${roomSeq * 31 + 7}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// K-1 在場者模型:群聊/自聊 session 的參與者名冊與共用場景記錄。
|
||||||
|
// 刻意採用純記憶體儲存(同 C-1 WorkingMemoryService 的設計),因為這是 session 期間的暫存場景,
|
||||||
|
// session 結束後會由 K-5 的固化流程萃取成各角色的長期記憶,原始記錄本身不需要落地保存。
|
||||||
|
@Injectable()
|
||||||
|
export class RoomService {
|
||||||
|
private readonly rooms = new Map<string, Room>();
|
||||||
|
|
||||||
|
create(userId: string, characterIds: string[], mode: RoomMode, topicSeed?: string): Room {
|
||||||
|
const room: Room = {
|
||||||
|
id: nextRoomId(),
|
||||||
|
userId,
|
||||||
|
mode,
|
||||||
|
status: "ACTIVE",
|
||||||
|
topicSeed,
|
||||||
|
participants: new Map(
|
||||||
|
characterIds.map((characterId) => [characterId, { characterId, silentStreak: 0, lastSpokeAtTurn: -1 }]),
|
||||||
|
),
|
||||||
|
turns: [],
|
||||||
|
turnCounter: 0,
|
||||||
|
};
|
||||||
|
this.rooms.set(room.id, room);
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(roomId: string): Room {
|
||||||
|
const room = this.rooms.get(roomId);
|
||||||
|
if (!room) {
|
||||||
|
throw new NotFoundException(`聊天室不存在:${roomId}`);
|
||||||
|
}
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
// K-1 驗收:角色(或呼叫端)可取得完整在場名單。
|
||||||
|
roster(roomId: string): string[] {
|
||||||
|
return [...this.get(roomId).participants.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
addTurn(
|
||||||
|
roomId: string,
|
||||||
|
speakerId: string,
|
||||||
|
speakerLabel: string,
|
||||||
|
content: string,
|
||||||
|
now: Date = new Date(),
|
||||||
|
emotion?: { tag: EmotionTag; intensity: number },
|
||||||
|
): RoomTurnRecord {
|
||||||
|
const room = this.get(roomId);
|
||||||
|
const turnIndex = room.turnCounter;
|
||||||
|
room.turnCounter += 1;
|
||||||
|
|
||||||
|
const turn: RoomTurnRecord = {
|
||||||
|
turnIndex,
|
||||||
|
speakerId,
|
||||||
|
speakerLabel,
|
||||||
|
content,
|
||||||
|
emotionTag: emotion?.tag,
|
||||||
|
emotionIntensity: emotion?.intensity,
|
||||||
|
timestamp: now,
|
||||||
|
};
|
||||||
|
room.turns.push(turn);
|
||||||
|
|
||||||
|
// 發言冷卻/被冷落累積:發言者本輪清空沉默計數,其他在場角色沉默計數 +1。
|
||||||
|
for (const participant of room.participants.values()) {
|
||||||
|
if (participant.characterId === speakerId) {
|
||||||
|
participant.lastSpokeAtTurn = turnIndex;
|
||||||
|
participant.silentStreak = 0;
|
||||||
|
} else {
|
||||||
|
participant.silentStreak += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return turn;
|
||||||
|
}
|
||||||
|
|
||||||
|
switchToGroup(roomId: string): Room {
|
||||||
|
const room = this.get(roomId);
|
||||||
|
room.mode = "GROUP";
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
end(roomId: string): Room {
|
||||||
|
const room = this.get(roomId);
|
||||||
|
room.status = "ENDED";
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(roomId: string): void {
|
||||||
|
this.rooms.delete(roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { RoomService, USER_SPEAKER_ID, type Room } from "./room.service.js";
|
||||||
|
import { SpeakingRightService } from "./speaking-right.service.js";
|
||||||
|
import { RoomGenerationService } from "./room-generation.service.js";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { SELF_CHAT_MAX_TURNS, STALL_CHECK_WINDOW, TOPIC_TWIST_LINES, SELF_CHAT_WRAP_UP_LINES } from "./constants.js";
|
||||||
|
|
||||||
|
function normalize(text: string): string {
|
||||||
|
return text.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 偵測最近幾輪是否開始空轉:內容高度重複,或字數持續下降(話越講越少,快聊不下去了)。
|
||||||
|
function isStalling(recentContents: string[]): boolean {
|
||||||
|
if (recentContents.length < STALL_CHECK_WINDOW) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const window = recentContents.slice(-STALL_CHECK_WINDOW);
|
||||||
|
const normalized = window.map(normalize);
|
||||||
|
const uniqueCount = new Set(normalized).size;
|
||||||
|
if (uniqueCount === 1) {
|
||||||
|
return true; // 完全重複
|
||||||
|
}
|
||||||
|
const lengths = window.map((text) => text.length);
|
||||||
|
const isMonotonicDecline = lengths.every((length, index) => index === 0 || length <= lengths[index - 1]);
|
||||||
|
return isMonotonicDecline && lengths[0] > lengths[lengths.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfChatAdvanceResult {
|
||||||
|
ended: boolean;
|
||||||
|
turn?: { characterId: string; content: string };
|
||||||
|
reason?: "topic-exhausted" | "turn-cap" | "stalled-out";
|
||||||
|
}
|
||||||
|
|
||||||
|
// K-6 角色自聊:話題種子驅動、發言權機制沿用 K-4、空轉偵測注入轉折或收尾、輪數硬上限。
|
||||||
|
@Injectable()
|
||||||
|
export class SelfChatService {
|
||||||
|
private readonly twistCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly room: RoomService,
|
||||||
|
private readonly speakingRight: SpeakingRightService,
|
||||||
|
private readonly generation: RoomGenerationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async start(userId: string, characterIds: string[], topicSeed: string, now: Date = new Date()) {
|
||||||
|
const room = this.room.create(userId, characterIds, "SELF_CHAT", topicSeed);
|
||||||
|
this.twistCounts.set(room.id, 0);
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
async advance(roomId: string, now: Date = new Date()): Promise<SelfChatAdvanceResult> {
|
||||||
|
const room = this.room.get(roomId);
|
||||||
|
if (room.mode !== "SELF_CHAT" || room.status !== "ACTIVE") {
|
||||||
|
return { ended: true, reason: "topic-exhausted" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (room.turnCounter >= SELF_CHAT_MAX_TURNS) {
|
||||||
|
return this.wrapUp(room, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastTurn = room.turns.at(-1);
|
||||||
|
const triggerSpeakerId = lastTurn?.speakerId ?? "__SEED__";
|
||||||
|
const triggerContent = lastTurn?.content ?? room.topicSeed ?? "今天過得怎麼樣?";
|
||||||
|
const triggerLabel = lastTurn?.speakerLabel ?? "話題";
|
||||||
|
|
||||||
|
const recentContents = room.turns.slice(-STALL_CHECK_WINDOW).map((turn) => turn.content);
|
||||||
|
const twistCount = this.twistCounts.get(room.id) ?? 0;
|
||||||
|
if (isStalling(recentContents)) {
|
||||||
|
if (twistCount >= 2) {
|
||||||
|
return this.wrapUp(room, now);
|
||||||
|
}
|
||||||
|
return this.injectTwist(room, twistCount, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = await this.speakingRight.decideSpeakers(room, triggerSpeakerId, triggerContent, now);
|
||||||
|
const speakerId = candidates[0]?.characterId ?? [...room.participants.keys()][0];
|
||||||
|
if (!speakerId) {
|
||||||
|
return this.wrapUp(room, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: speakerId } });
|
||||||
|
const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId: speakerId } });
|
||||||
|
const speakerLabel = alias?.nickname || alias?.formalName || character.id;
|
||||||
|
|
||||||
|
const reply = await this.generation.generateReply(room, speakerId, triggerSpeakerId, triggerLabel, triggerContent, {
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
this.room.addTurn(roomId, speakerId, speakerLabel, reply.text, now);
|
||||||
|
|
||||||
|
return { ended: false, turn: { characterId: speakerId, content: reply.text } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用者插話:切換為群聊模式,插話內容本身視為使用者的第一則群聊訊息。
|
||||||
|
async interject(roomId: string): Promise<Room> {
|
||||||
|
return this.room.switchToGroup(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async injectTwist(room: Room, twistCount: number, now: Date): Promise<SelfChatAdvanceResult> {
|
||||||
|
const speakerId = [...room.participants.keys()][twistCount % room.participants.size];
|
||||||
|
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: speakerId } });
|
||||||
|
const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId: speakerId } });
|
||||||
|
const speakerLabel = alias?.nickname || alias?.formalName || character.id;
|
||||||
|
const line = TOPIC_TWIST_LINES[twistCount % TOPIC_TWIST_LINES.length];
|
||||||
|
|
||||||
|
this.room.addTurn(room.id, speakerId, speakerLabel, line, now);
|
||||||
|
this.twistCounts.set(room.id, twistCount + 1);
|
||||||
|
return { ended: false, turn: { characterId: speakerId, content: line } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async wrapUp(room: Room, now: Date): Promise<SelfChatAdvanceResult> {
|
||||||
|
const speakerId = [...room.participants.keys()][0];
|
||||||
|
if (speakerId) {
|
||||||
|
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: speakerId } });
|
||||||
|
const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId: speakerId } });
|
||||||
|
const speakerLabel = alias?.nickname || alias?.formalName || character.id;
|
||||||
|
const line = SELF_CHAT_WRAP_UP_LINES[room.turnCounter % SELF_CHAT_WRAP_UP_LINES.length];
|
||||||
|
this.room.addTurn(room.id, speakerId, speakerLabel, line, now);
|
||||||
|
}
|
||||||
|
this.twistCounts.delete(room.id);
|
||||||
|
this.room.end(room.id);
|
||||||
|
return {
|
||||||
|
ended: true,
|
||||||
|
reason: room.turnCounter >= SELF_CHAT_MAX_TURNS ? "turn-cap" : "stalled-out",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
||||||
|
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||||
|
import { getArchetypeParams } from "../personality/archetype-params.js";
|
||||||
|
import { CharacterRelationshipService } from "./character-relationship.service.js";
|
||||||
|
import { type Room, USER_SPEAKER_ID } from "./room.service.js";
|
||||||
|
import {
|
||||||
|
SPEAKING_THRESHOLD,
|
||||||
|
NAMED_BOOST,
|
||||||
|
BASELINE_WEIGHT,
|
||||||
|
TOPIC_RELEVANCE_WEIGHT,
|
||||||
|
EMOTION_WEIGHT,
|
||||||
|
RELATIONSHIP_WEIGHT,
|
||||||
|
SPEAK_COOLDOWN_PENALTY,
|
||||||
|
SILENT_STREAK_BONUS_PER_TURN,
|
||||||
|
SILENT_STREAK_BONUS_CAP,
|
||||||
|
EMOTION_SPEAKING_MODIFIER,
|
||||||
|
} from "./constants.js";
|
||||||
|
|
||||||
|
export interface SpeakingImpulse {
|
||||||
|
characterId: string;
|
||||||
|
score: number;
|
||||||
|
isNamed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keywordOverlap(text: string, corpus: string): number {
|
||||||
|
// 中文常見以單字成詞(貓/狗/書),門檻不能設 >=2,否則會漏掉大量真實關鍵字。
|
||||||
|
const keywords = corpus.split(/[、,,;::\s]+/).filter((word) => word.length >= 1);
|
||||||
|
if (keywords.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const hits = keywords.filter((keyword) => text.includes(keyword)).length;
|
||||||
|
if (hits === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// 只要命中,就是明確訊號(哪怕只命中角色喜好清單裡的一個詞):至少給予下限分數,
|
||||||
|
// 不讓「喜好清單條目多」稀釋掉單次命中的重要性。
|
||||||
|
return Math.max(0.5, hits / keywords.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// K-4 發言權分配:每輪為每位在場角色計算「發言衝動值」,超過門檻者本輪發言。
|
||||||
|
@Injectable()
|
||||||
|
export class SpeakingRightService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly emotion: EmotionService,
|
||||||
|
private readonly relationship: RelationshipService,
|
||||||
|
private readonly characterRelationship: CharacterRelationshipService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async computeImpulse(
|
||||||
|
room: Room,
|
||||||
|
characterId: string,
|
||||||
|
speakerId: string,
|
||||||
|
content: string,
|
||||||
|
now: Date,
|
||||||
|
): Promise<SpeakingImpulse> {
|
||||||
|
const [character, aliases] = await Promise.all([
|
||||||
|
this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } }),
|
||||||
|
this.prisma.client.characterAlias.findMany({ where: { characterId } }),
|
||||||
|
]);
|
||||||
|
const archetypeParams = getArchetypeParams(character.personalityArchetype);
|
||||||
|
|
||||||
|
const isNamed = aliases.some(
|
||||||
|
(alias) =>
|
||||||
|
(alias.formalName && content.includes(alias.formalName)) ||
|
||||||
|
(alias.nickname && content.includes(alias.nickname)) ||
|
||||||
|
(alias.calledByOthers && content.includes(alias.calledByOthers)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const topicCorpus = [character.likesDislikes, character.goalsObsessions, character.basicInfo].join("、");
|
||||||
|
const topicRelevance = keywordOverlap(content, topicCorpus);
|
||||||
|
|
||||||
|
const emotionState = await this.emotion.getState(characterId, now);
|
||||||
|
const emotionModifier = EMOTION_SPEAKING_MODIFIER[dominantState(emotionState)];
|
||||||
|
|
||||||
|
let relationshipModifier = 0;
|
||||||
|
if (speakerId === USER_SPEAKER_ID) {
|
||||||
|
const { relationship } = await this.relationship.getState(characterId, room.userId, now);
|
||||||
|
relationshipModifier = (relationship.intimacy - 50) / 100;
|
||||||
|
} else if (speakerId !== characterId) {
|
||||||
|
const affinity = await this.characterRelationship.getAffinity(characterId, speakerId);
|
||||||
|
relationshipModifier = (affinity - 50) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const participant = room.participants.get(characterId);
|
||||||
|
const justSpoke = participant?.lastSpokeAtTurn === room.turnCounter - 1;
|
||||||
|
const cooldownPenalty = justSpoke ? SPEAK_COOLDOWN_PENALTY : 0;
|
||||||
|
const silentStreakBonus = Math.min(
|
||||||
|
SILENT_STREAK_BONUS_CAP,
|
||||||
|
(participant?.silentStreak ?? 0) * SILENT_STREAK_BONUS_PER_TURN,
|
||||||
|
);
|
||||||
|
|
||||||
|
let score =
|
||||||
|
archetypeParams.conversationBaseline * BASELINE_WEIGHT +
|
||||||
|
topicRelevance * TOPIC_RELEVANCE_WEIGHT +
|
||||||
|
emotionModifier * EMOTION_WEIGHT +
|
||||||
|
relationshipModifier * RELATIONSHIP_WEIGHT +
|
||||||
|
silentStreakBonus -
|
||||||
|
cooldownPenalty;
|
||||||
|
if (isNamed) {
|
||||||
|
score += NAMED_BOOST;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { characterId, score: Math.max(0, Math.min(1.6, score)), isNamed };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回傳本輪應該發言的角色(依衝動值高到低排序),排除發言者本人。
|
||||||
|
async decideSpeakers(room: Room, speakerId: string, content: string, now: Date = new Date()): Promise<SpeakingImpulse[]> {
|
||||||
|
const candidates = [...room.participants.keys()].filter((characterId) => characterId !== speakerId);
|
||||||
|
const impulses = await Promise.all(
|
||||||
|
candidates.map((characterId) => this.computeImpulse(room, characterId, speakerId, content, now)),
|
||||||
|
);
|
||||||
|
return impulses.filter((impulse) => impulse.score >= SPEAKING_THRESHOLD).sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { ScheduleDayType, ScheduleSlotStatus } from "@kokorone/db";
|
||||||
|
|
||||||
|
export interface DefaultSlotSeed {
|
||||||
|
dayType: ScheduleDayType;
|
||||||
|
startMinute: number;
|
||||||
|
endMinute: number;
|
||||||
|
status: ScheduleSlotStatus;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// I-1 高中生角色的預設平日/週末作息表(依 wiki §作息表設計的甘特圖範例)。
|
||||||
|
// 跨午夜的睡眠時段拆成兩列(前一天 23:00~24:00 與當天 00:00~06:30)。
|
||||||
|
export const DEFAULT_HIGH_SCHOOLER_SCHEDULE: DefaultSlotSeed[] = [
|
||||||
|
// 平日
|
||||||
|
{ dayType: "WEEKDAY", startMinute: 0, endMinute: 390, status: "SLEEP", label: "睡眠" },
|
||||||
|
{ dayType: "WEEKDAY", startMinute: 390, endMinute: 510, status: "SEMI_BUSY", label: "晨間準備/通學" },
|
||||||
|
{ dayType: "WEEKDAY", startMinute: 510, endMinute: 960, status: "BUSY", label: "上課" },
|
||||||
|
{ dayType: "WEEKDAY", startMinute: 960, endMinute: 1080, status: "BUSY", label: "社團活動" },
|
||||||
|
{ dayType: "WEEKDAY", startMinute: 1080, endMinute: 1140, status: "SEMI_BUSY", label: "回家路上" },
|
||||||
|
{ dayType: "WEEKDAY", startMinute: 1140, endMinute: 1260, status: "SEMI_BUSY", label: "晚餐/家庭時間" },
|
||||||
|
{ dayType: "WEEKDAY", startMinute: 1260, endMinute: 1380, status: "FREE", label: "自由時間" },
|
||||||
|
{ dayType: "WEEKDAY", startMinute: 1380, endMinute: 1440, status: "SLEEP", label: "睡眠" },
|
||||||
|
// 週末(睡得晚一點、多數時間空閒)
|
||||||
|
{ dayType: "WEEKEND", startMinute: 0, endMinute: 480, status: "SLEEP", label: "睡眠" },
|
||||||
|
{ dayType: "WEEKEND", startMinute: 480, endMinute: 720, status: "FREE", label: "悠閒早晨" },
|
||||||
|
{ dayType: "WEEKEND", startMinute: 720, endMinute: 780, status: "SEMI_BUSY", label: "午餐/家庭時間" },
|
||||||
|
{ dayType: "WEEKEND", startMinute: 780, endMinute: 1320, status: "FREE", label: "自由時間" },
|
||||||
|
{ dayType: "WEEKEND", startMinute: 1320, endMinute: 1440, status: "SLEEP", label: "睡眠" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WAKE_WINDOW_MINUTES = 30; // 剛醒的迷糊演出窗口
|
||||||
|
export const PRE_SLEEP_WINDOW_MINUTES = 30; // 睡前的慵懶演出窗口
|
||||||
|
|
||||||
|
export type AvailabilityMode = "NORMAL" | "BRIEF" | "DELAYED" | "NO_REPLY";
|
||||||
|
|
||||||
|
// I-2 時段狀態 → 回應可用度
|
||||||
|
export function availabilityForStatus(status: ScheduleSlotStatus | null): AvailabilityMode {
|
||||||
|
switch (status) {
|
||||||
|
case "FREE":
|
||||||
|
return "NORMAL";
|
||||||
|
case "SEMI_BUSY":
|
||||||
|
return "BRIEF";
|
||||||
|
case "BUSY":
|
||||||
|
return "DELAYED";
|
||||||
|
case "SLEEP":
|
||||||
|
return "NO_REPLY";
|
||||||
|
default:
|
||||||
|
return "NORMAL"; // 沒有作息資料時預設正常回應,不因缺資料而封鎖對話
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExceptionType = "LATE_NIGHT_CHAT" | "SNEAK_REPLY_IN_CLASS" | "ADJUST_SCHEDULE";
|
||||||
|
|
||||||
|
// I-4 破例規則:必須稀有,因此每種破例都有各自的冷卻天數。
|
||||||
|
export const EXCEPTION_COOLDOWN_DAYS: Record<ExceptionType, number> = {
|
||||||
|
LATE_NIGHT_CHAT: 7,
|
||||||
|
SNEAK_REPLY_IN_CLASS: 10,
|
||||||
|
ADJUST_SCHEDULE: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EXCEPTION_INTIMACY_THRESHOLD: Record<ExceptionType, number> = {
|
||||||
|
LATE_NIGHT_CHAT: 60, // 摯友-曖昧以上
|
||||||
|
SNEAK_REPLY_IN_CLASS: 80, // 羈絆級
|
||||||
|
ADJUST_SCHEDULE: 80, // 羈絆級
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EXCEPTION_LEDGER_EVENT: Record<ExceptionType, string> = {
|
||||||
|
LATE_NIGHT_CHAT: "深夜還是抽空陪你聊天",
|
||||||
|
SNEAK_REPLY_IN_CLASS: "上課中還偷偷回你訊息",
|
||||||
|
ADJUST_SCHEDULE: "為了你調整了自己的行程",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const URGENT_KEYWORDS = ["緊急", "快點", "立刻", "馬上", "救命"];
|
||||||
|
export const SCHEDULE_ADJUST_REQUEST_KEYWORDS = ["可以陪我", "要不要一起", "今天可以出來", "有空嗎", "陪我一下"];
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { ScheduleService } from "./schedule.service.js";
|
||||||
|
|
||||||
|
// I-6 離線事件生成的模板庫,鍵為固定行程的 label;一致性約束:只依「今天已經發生過」的
|
||||||
|
// 固定行程挑選對應模板,不會生成角色作息表裡沒有的活動。
|
||||||
|
const OFFLINE_EVENT_TEMPLATES: Record<string, string[]> = {
|
||||||
|
社團活動: ["今天社團練習時出了點小糗,大家都笑翻了", "社團今天氣氛超好,練得特別起勁"],
|
||||||
|
上課: ["今天上課差點打瞌睡,還好沒被抓到", "今天課堂上聽到一個超有趣的小知識"],
|
||||||
|
自由時間: ["剛剛看到夕陽超美,忍不住多看了好幾眼", "吃到一款新口味的點心,好吃到想再買"],
|
||||||
|
悠閒早晨: ["今天早上賴床賴了好久,超舒服"],
|
||||||
|
};
|
||||||
|
const DEFAULT_OFFLINE_EVENT_TEMPLATES = ["今天過得普普通通,沒什麼特別的事"];
|
||||||
|
|
||||||
|
// I-6 離線生活:不在線時她在活著,於下次對話開始時按需生成合理的日常小事。
|
||||||
|
@Injectable()
|
||||||
|
export class OfflineEventService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly schedule: ScheduleService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async maybeGenerate(characterId: string, now: Date = new Date()): Promise<void> {
|
||||||
|
const todayStart = this.schedule.startOfToday(now);
|
||||||
|
const existing = await this.prisma.client.episodicMemory.findFirst({
|
||||||
|
where: { characterId, source: "OFFLINE_GENERATED", occurredAt: { gte: todayStart } },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pastSlots = await this.schedule.getPastSlotsToday(characterId, now);
|
||||||
|
const labelledSlots = pastSlots.filter((slot) => slot.label && slot.status !== "SLEEP");
|
||||||
|
const label = labelledSlots.at(-1)?.label ?? null;
|
||||||
|
|
||||||
|
const templates = (label && OFFLINE_EVENT_TEMPLATES[label]) || DEFAULT_OFFLINE_EVENT_TEMPLATES;
|
||||||
|
const content = templates[Math.floor(Math.random() * templates.length)];
|
||||||
|
|
||||||
|
await this.prisma.client.episodicMemory.create({
|
||||||
|
data: {
|
||||||
|
characterId,
|
||||||
|
content,
|
||||||
|
occurredAt: now,
|
||||||
|
emotionTag: "CALM",
|
||||||
|
emotionIntensity: 0.3,
|
||||||
|
source: "OFFLINE_GENERATED",
|
||||||
|
weight: 0.5, // 低權重日常事件,供 C-5 自然遺忘
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { EmotionTag } from "@kokorone/shared";
|
||||||
|
import { ScheduleService } from "./schedule.service.js";
|
||||||
|
|
||||||
|
export interface BaselineSignal {
|
||||||
|
tag: EmotionTag;
|
||||||
|
intensity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLUB_TIRED_WINDOW_MINUTES = 30;
|
||||||
|
|
||||||
|
// I-5 作息驅動情緒基線:社團剛結束的疲憊、假日心情基線偏高等日內波動。
|
||||||
|
// 只在本輪對話「沒有明確情緒訊號」時介入,不覆蓋使用者輸入本身觸發的情緒。
|
||||||
|
@Injectable()
|
||||||
|
export class ScheduleEmotionBaselineService {
|
||||||
|
constructor(private readonly schedule: ScheduleService) {}
|
||||||
|
|
||||||
|
async computeBaseline(characterId: string, now: Date = new Date()): Promise<BaselineSignal | null> {
|
||||||
|
const justEnded = await this.schedule.slotJustEnded(characterId, now, CLUB_TIRED_WINDOW_MINUTES);
|
||||||
|
if (justEnded?.label?.includes("社團")) {
|
||||||
|
return { tag: "SAD", intensity: 0.3 }; // 累
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayType = await this.schedule.getDayType(characterId, now);
|
||||||
|
if (dayType === "HOLIDAY" || dayType === "WEEKEND") {
|
||||||
|
return { tag: "JOY", intensity: 0.3 }; // 放假日心情基線偏高
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { EmotionTag } from "@kokorone/shared";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { SentimentLedgerService } from "../relationship/sentiment-ledger.service.js";
|
||||||
|
import {
|
||||||
|
type AvailabilityMode,
|
||||||
|
type ExceptionType,
|
||||||
|
EXCEPTION_COOLDOWN_DAYS,
|
||||||
|
EXCEPTION_INTIMACY_THRESHOLD,
|
||||||
|
EXCEPTION_LEDGER_EVENT,
|
||||||
|
URGENT_KEYWORDS,
|
||||||
|
SCHEDULE_ADJUST_REQUEST_KEYWORDS,
|
||||||
|
} from "./constants.js";
|
||||||
|
|
||||||
|
export interface ExceptionContext {
|
||||||
|
intimacy: number;
|
||||||
|
dominantEmotion: EmotionTag;
|
||||||
|
userInput: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// I-4 破例規則:作息規則平常確實執行,破例必須稀有才有重量——條件不足時完全不破例。
|
||||||
|
@Injectable()
|
||||||
|
export class ScheduleExceptionService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly sentimentLedger: SentimentLedgerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// 依目前的作息限制模式與情境,判斷是否符合任一破例的「資格條件」(尚未套用冷卻判定)。
|
||||||
|
private pickCandidate(mode: AvailabilityMode, context: ExceptionContext): ExceptionType | null {
|
||||||
|
if (mode === "NO_REPLY" && context.dominantEmotion === "SAD" && context.intimacy >= EXCEPTION_INTIMACY_THRESHOLD.LATE_NIGHT_CHAT) {
|
||||||
|
return "LATE_NIGHT_CHAT";
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUrgent = URGENT_KEYWORDS.some((keyword) => context.userInput.includes(keyword));
|
||||||
|
if (mode === "DELAYED" && isUrgent && context.intimacy >= EXCEPTION_INTIMACY_THRESHOLD.SNEAK_REPLY_IN_CLASS) {
|
||||||
|
return "SNEAK_REPLY_IN_CLASS";
|
||||||
|
}
|
||||||
|
|
||||||
|
const isScheduleAdjustRequest = SCHEDULE_ADJUST_REQUEST_KEYWORDS.some((keyword) =>
|
||||||
|
context.userInput.includes(keyword),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
(mode === "DELAYED" || mode === "NO_REPLY") &&
|
||||||
|
isScheduleAdjustRequest &&
|
||||||
|
context.intimacy >= EXCEPTION_INTIMACY_THRESHOLD.ADJUST_SCHEDULE
|
||||||
|
) {
|
||||||
|
return "ADJUST_SCHEDULE";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回傳實際觸發的破例類型;資格不符或冷卻中一律回傳 null(維持原本的作息限制)。
|
||||||
|
async tryApply(
|
||||||
|
characterId: string,
|
||||||
|
userId: string,
|
||||||
|
mode: AvailabilityMode,
|
||||||
|
context: ExceptionContext,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): Promise<ExceptionType | null> {
|
||||||
|
if (mode === "NORMAL") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = this.pickCandidate(mode, context);
|
||||||
|
if (!candidate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = await this.prisma.client.scheduleExceptionLog.findFirst({
|
||||||
|
where: { characterId, exceptionType: candidate },
|
||||||
|
orderBy: { triggeredAt: "desc" },
|
||||||
|
});
|
||||||
|
if (last) {
|
||||||
|
const cooldownMs = EXCEPTION_COOLDOWN_DAYS[candidate] * 24 * 60 * 60 * 1000;
|
||||||
|
if (now.getTime() - last.triggeredAt.getTime() < cooldownMs) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.client.scheduleExceptionLog.create({
|
||||||
|
data: { characterId, exceptionType: candidate, triggeredAt: now },
|
||||||
|
});
|
||||||
|
await this.sentimentLedger.recordEvent(characterId, userId, EXCEPTION_LEDGER_EVENT[candidate], 5, { now });
|
||||||
|
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||||
|
import type { EmotionTag } from "@kokorone/shared";
|
||||||
|
import { ScheduleService } from "./schedule.service.js";
|
||||||
|
import { ScheduleExceptionService } from "./schedule-exception.service.js";
|
||||||
|
import { ScheduleEmotionBaselineService } from "./schedule-emotion-baseline.service.js";
|
||||||
|
import { OfflineEventService } from "./offline-event.service.js";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { availabilityForStatus } from "./constants.js";
|
||||||
|
|
||||||
|
interface SpecialDayBody {
|
||||||
|
date: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExceptionBody {
|
||||||
|
userId: string;
|
||||||
|
mode: "NORMAL" | "BRIEF" | "DELAYED" | "NO_REPLY";
|
||||||
|
intimacy: number;
|
||||||
|
dominantEmotion: EmotionTag;
|
||||||
|
userInput: string;
|
||||||
|
now?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller("schedule")
|
||||||
|
export class ScheduleController {
|
||||||
|
constructor(
|
||||||
|
private readonly schedule: ScheduleService,
|
||||||
|
private readonly exception: ScheduleExceptionService,
|
||||||
|
private readonly baseline: ScheduleEmotionBaselineService,
|
||||||
|
private readonly offlineEvent: OfflineEventService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post(":characterId/seed-default")
|
||||||
|
async seedDefault(@Param("characterId") characterId: string) {
|
||||||
|
await this.schedule.seedDefaultSchedule(characterId);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":characterId/special-days")
|
||||||
|
async addSpecialDay(@Param("characterId") characterId: string, @Body() body: SpecialDayBody) {
|
||||||
|
return this.prisma.client.specialDay.create({
|
||||||
|
data: { characterId, date: new Date(body.date), label: body.label },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":characterId/info")
|
||||||
|
async getInfo(@Param("characterId") characterId: string, @Query("now") now?: string) {
|
||||||
|
const info = await this.schedule.getInfo(characterId, now ? new Date(now) : undefined);
|
||||||
|
return { ...info, availability: availabilityForStatus(info.slot?.status ?? null) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":characterId/exception")
|
||||||
|
async tryException(@Param("characterId") characterId: string, @Body() body: ExceptionBody) {
|
||||||
|
const exceptionType = await this.exception.tryApply(
|
||||||
|
characterId,
|
||||||
|
body.userId,
|
||||||
|
body.mode,
|
||||||
|
{ intimacy: body.intimacy, dominantEmotion: body.dominantEmotion, userInput: body.userInput },
|
||||||
|
body.now ? new Date(body.now) : undefined,
|
||||||
|
);
|
||||||
|
return { exceptionType };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":characterId/baseline")
|
||||||
|
async getBaseline(@Param("characterId") characterId: string, @Query("now") now?: string) {
|
||||||
|
const baseline = await this.baseline.computeBaseline(characterId, now ? new Date(now) : undefined);
|
||||||
|
return { baseline };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":characterId/offline-event")
|
||||||
|
async generateOfflineEvent(@Param("characterId") characterId: string, @Body() body: { now?: string }) {
|
||||||
|
await this.offlineEvent.maybeGenerate(characterId, body.now ? new Date(body.now) : undefined);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||||
|
import { RelationshipModule } from "../relationship/relationship.module.js";
|
||||||
|
import { ScheduleService } from "./schedule.service.js";
|
||||||
|
import { ScheduleExceptionService } from "./schedule-exception.service.js";
|
||||||
|
import { ScheduleEmotionBaselineService } from "./schedule-emotion-baseline.service.js";
|
||||||
|
import { OfflineEventService } from "./offline-event.service.js";
|
||||||
|
import { ScheduleController } from "./schedule.controller.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, RelationshipModule],
|
||||||
|
controllers: [ScheduleController],
|
||||||
|
providers: [ScheduleService, ScheduleExceptionService, ScheduleEmotionBaselineService, OfflineEventService],
|
||||||
|
exports: [ScheduleService, ScheduleExceptionService, ScheduleEmotionBaselineService, OfflineEventService],
|
||||||
|
})
|
||||||
|
export class ScheduleModule {}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { ScheduleDayType } from "@kokorone/db";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { DEFAULT_HIGH_SCHOOLER_SCHEDULE, WAKE_WINDOW_MINUTES, PRE_SLEEP_WINDOW_MINUTES } from "./constants.js";
|
||||||
|
import { minutesOfDay, taipeiParts, isSameTaipeiDate, startOfTaipeiDay } from "./time-utils.js";
|
||||||
|
|
||||||
|
export interface CurrentSlotInfo {
|
||||||
|
dayType: ScheduleDayType;
|
||||||
|
minute: number;
|
||||||
|
slot: Awaited<ReturnType<PrismaService["client"]["scheduleSlot"]["findFirst"]>> | null;
|
||||||
|
isWaking: boolean;
|
||||||
|
isPreSleep: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// I-1/I-3 作息表與時間感知:週期表判定、目前時段查詢、剛醒/睡前偵測。
|
||||||
|
@Injectable()
|
||||||
|
export class ScheduleService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// I-1 驗收:可為種子角色寫入一份高中生作息表(可重複執行)。
|
||||||
|
async seedDefaultSchedule(characterId: string): Promise<void> {
|
||||||
|
await this.prisma.client.scheduleSlot.deleteMany({ where: { characterId } });
|
||||||
|
await this.prisma.client.scheduleSlot.createMany({
|
||||||
|
data: DEFAULT_HIGH_SCHOOLER_SCHEDULE.map((slot) => ({ characterId, ...slot })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDayType(characterId: string, now: Date = new Date()): Promise<ScheduleDayType> {
|
||||||
|
const specialDays = await this.prisma.client.specialDay.findMany({ where: { characterId } });
|
||||||
|
if (specialDays.some((day) => isSameTaipeiDate(day.date, now))) {
|
||||||
|
return "HOLIDAY";
|
||||||
|
}
|
||||||
|
const { weekday } = taipeiParts(now);
|
||||||
|
return weekday === 0 || weekday === 6 ? "WEEKEND" : "WEEKDAY";
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentSlot(characterId: string, now: Date = new Date()) {
|
||||||
|
const dayType = await this.getDayType(characterId, now);
|
||||||
|
const minute = minutesOfDay(now);
|
||||||
|
return this.prisma.client.scheduleSlot.findFirst({
|
||||||
|
where: { characterId, dayType, startMinute: { lte: minute }, endMinute: { gt: minute } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInfo(characterId: string, now: Date = new Date()): Promise<CurrentSlotInfo> {
|
||||||
|
const dayType = await this.getDayType(characterId, now);
|
||||||
|
const minute = minutesOfDay(now);
|
||||||
|
const slot = await this.getCurrentSlot(characterId, now);
|
||||||
|
|
||||||
|
const allSlots = await this.prisma.client.scheduleSlot.findMany({ where: { characterId, dayType } });
|
||||||
|
const isWaking = allSlots.some(
|
||||||
|
(s) => s.status === "SLEEP" && minute >= s.endMinute && minute < s.endMinute + WAKE_WINDOW_MINUTES,
|
||||||
|
);
|
||||||
|
const isPreSleep = allSlots.some(
|
||||||
|
(s) => s.status === "SLEEP" && minute <= s.startMinute && minute > s.startMinute - PRE_SLEEP_WINDOW_MINUTES,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { dayType, minute, slot, isWaking, isPreSleep };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 供 I-5/I-6 使用:今天到目前為止已經「結束」的固定行程時段(依開始時間排序)。
|
||||||
|
async getPastSlotsToday(characterId: string, now: Date = new Date()) {
|
||||||
|
const dayType = await this.getDayType(characterId, now);
|
||||||
|
const minute = minutesOfDay(now);
|
||||||
|
const slots = await this.prisma.client.scheduleSlot.findMany({
|
||||||
|
where: { characterId, dayType, endMinute: { lte: minute } },
|
||||||
|
orderBy: { startMinute: "asc" },
|
||||||
|
});
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 供 I-5 使用:某個固定行程是否在最近 windowMinutes 分鐘內剛結束。
|
||||||
|
async slotJustEnded(characterId: string, now: Date = new Date(), windowMinutes = 30) {
|
||||||
|
const dayType = await this.getDayType(characterId, now);
|
||||||
|
const minute = minutesOfDay(now);
|
||||||
|
return this.prisma.client.scheduleSlot.findFirst({
|
||||||
|
where: {
|
||||||
|
characterId,
|
||||||
|
dayType,
|
||||||
|
endMinute: { lte: minute, gt: minute - windowMinutes },
|
||||||
|
},
|
||||||
|
orderBy: { endMinute: "desc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startOfToday(now: Date = new Date()): Date {
|
||||||
|
return startOfTaipeiDay(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// I-3 時間感知:所有作息判斷一律以 Asia/Taipei 當地時間為準,不受伺服器所在時區影響。
|
||||||
|
const TAIPEI_PARTS_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: "Asia/Taipei",
|
||||||
|
weekday: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
const WEEKDAY_INDEX: Record<string, number> = {
|
||||||
|
Sun: 0,
|
||||||
|
Mon: 1,
|
||||||
|
Tue: 2,
|
||||||
|
Wed: 3,
|
||||||
|
Thu: 4,
|
||||||
|
Fri: 5,
|
||||||
|
Sat: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TaipeiTimeParts {
|
||||||
|
weekday: number; // 0=週日 ... 6=週六
|
||||||
|
hour: number;
|
||||||
|
minute: number;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
day: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function taipeiParts(date: Date): TaipeiTimeParts {
|
||||||
|
const parts = TAIPEI_PARTS_FORMATTER.formatToParts(date);
|
||||||
|
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
|
||||||
|
return {
|
||||||
|
weekday: WEEKDAY_INDEX[get("weekday")] ?? 0,
|
||||||
|
hour: Number(get("hour")),
|
||||||
|
minute: Number(get("minute")),
|
||||||
|
year: Number(get("year")),
|
||||||
|
month: Number(get("month")),
|
||||||
|
day: Number(get("day")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minutesOfDay(date: Date): number {
|
||||||
|
const { hour, minute } = taipeiParts(date);
|
||||||
|
return hour * 60 + minute;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSameTaipeiDate(a: Date, b: Date): boolean {
|
||||||
|
const pa = taipeiParts(a);
|
||||||
|
const pb = taipeiParts(b);
|
||||||
|
return pa.year === pb.year && pa.month === pb.month && pa.day === pb.day;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回傳「當地日期」的午夜時間點(以 UTC Date 物件表示,僅用於資料庫時間比較的下界)。
|
||||||
|
export function startOfTaipeiDay(date: Date): Date {
|
||||||
|
const { year, month, day } = taipeiParts(date);
|
||||||
|
// Asia/Taipei 為 UTC+8,當地 00:00 對應 UTC 前一天 16:00。
|
||||||
|
return new Date(Date.UTC(year, month - 1, day, -8, 0, 0, 0));
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { EmotionTag } from "@kokorone/shared";
|
||||||
|
|
||||||
|
// O-1 立繪分層合成資產目錄(差分=可替換部件)。實際美術資產尚未存在,這裡先定義規範與佔位用的
|
||||||
|
// 部件識別字串——之後接上真的差分素材時,只要讓對應識別字串指到真實檔案即可,呼叫端(前端渲染器)
|
||||||
|
// 完全不用改。
|
||||||
|
export const BODY_POSE_OPTIONS = ["預設站姿", "抱胸", "前傾", "側身", "肩膀微垂"] as const;
|
||||||
|
export const COSTUME_DEFAULT_OPTIONS = ["制服", "便服"] as const; // M-1 CostumeCatalogEntry 有登錄時優先使用那份目錄
|
||||||
|
export const EYEBROW_OPTIONS = ["自然", "舒展", "八字眉", "微皺", "八字微皺", "上挑"] as const;
|
||||||
|
export const EYE_OPTIONS = ["平視", "彎月眼", "半垂視線", "直視", "視線飄開", "閉眼撇開"] as const;
|
||||||
|
export const MOUTH_OPTIONS = ["微閉", "上揚", "抿嘴", "抿直", "小口欲言又止", "嘟嘴"] as const;
|
||||||
|
export const EFFECT_OPTIONS = ["臉紅", "汗", "淚", "青筋", "音符"] as const;
|
||||||
|
export const MICRO_MOTIONS = ["眨眼", "呼吸"] as const; // 常駐,不受情緒/親密度節制
|
||||||
|
|
||||||
|
export interface ExpressionTemplate {
|
||||||
|
eyebrow: string;
|
||||||
|
eye: string;
|
||||||
|
mouth: string;
|
||||||
|
effect: string | null;
|
||||||
|
pose: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// O-4 情緒 → 表情對照表:情緒狀態機直接驅動表情差分,六狀態各自的眉/眼/口/效果層/姿勢。
|
||||||
|
export const EMOTION_EXPRESSION_TABLE: Record<EmotionTag, ExpressionTemplate> = {
|
||||||
|
CALM: { eyebrow: "自然", eye: "平視", mouth: "微閉", effect: null, pose: "預設站姿" },
|
||||||
|
JOY: { eyebrow: "舒展", eye: "彎月眼", mouth: "上揚", effect: "音符", pose: "前傾" },
|
||||||
|
SAD: { eyebrow: "八字眉", eye: "半垂視線", mouth: "抿嘴", effect: null, pose: "肩膀微垂" },
|
||||||
|
ALERT: { eyebrow: "微皺", eye: "直視", mouth: "抿直", effect: null, pose: "抱胸" },
|
||||||
|
SHY: { eyebrow: "八字微皺", eye: "視線飄開", mouth: "小口欲言又止", effect: "臉紅", pose: "側身" },
|
||||||
|
GRUMPY: { eyebrow: "上挑", eye: "閉眼撇開", mouth: "嘟嘴", effect: "汗", pose: "抱胸別過頭" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GazeDirection = "UP" | "AWAY" | "DIRECT" | "NEUTRAL";
|
||||||
|
|
||||||
|
// O-3 親密度與外觀解鎖:陌生~認識/朋友/摯友/羈絆對應既有的 RelationshipStage 四個可觀察分層
|
||||||
|
// (STRANGER/ACQUAINTANCE 合併視為「陌生~認識」)。哭臉(極度低落時的淚光特效)放在最深層,
|
||||||
|
// 非 BONDED 不可用——即使情緒引擎判定她該哭,親密度不到就是不會在你面前哭。
|
||||||
|
export const BASIC_TIER_EMOTIONS: EmotionTag[] = ["CALM", "JOY"];
|
||||||
|
export const FRIEND_TIER_EMOTIONS: EmotionTag[] = ["CALM", "JOY", "SAD", "ALERT"];
|
||||||
|
export const CLOSE_TIER_EMOTIONS: EmotionTag[] = ["CALM", "JOY", "SAD", "ALERT", "SHY", "GRUMPY"];
|
||||||
|
|
||||||
|
export const FRIEND_TIER_COSTUMES = ["制服", "便服"];
|
||||||
|
export const CLOSE_TIER_COSTUMES = ["制服", "便服", "居家服"];
|
||||||
|
|
||||||
|
export const CRYING_EFFECT = "淚";
|
||||||
|
export const CRYING_INTENSITY_THRESHOLD = 0.75; // 情緒強度達此值以上的低落,才夠格被視為「哭臉」候選
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { EmotionTag, EmotionState } from "@kokorone/shared";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
||||||
|
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||||
|
import { getArchetypeParams } from "../personality/archetype-params.js";
|
||||||
|
import {
|
||||||
|
EMOTION_EXPRESSION_TABLE,
|
||||||
|
BASIC_TIER_EMOTIONS,
|
||||||
|
FRIEND_TIER_EMOTIONS,
|
||||||
|
CLOSE_TIER_EMOTIONS,
|
||||||
|
FRIEND_TIER_COSTUMES,
|
||||||
|
CLOSE_TIER_COSTUMES,
|
||||||
|
CRYING_EFFECT,
|
||||||
|
CRYING_INTENSITY_THRESHOLD,
|
||||||
|
type GazeDirection,
|
||||||
|
} from "./constants.js";
|
||||||
|
|
||||||
|
export interface ResolveExpressionOptions {
|
||||||
|
now?: Date;
|
||||||
|
isThinking?: boolean; // O-2 視線:思考時視線飄上
|
||||||
|
isDeflecting?: boolean; // O-2 視線:說謊/迴避時視線撇開(跟 SHY 的視線飄開共用同一個方向)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpressionState {
|
||||||
|
emotionTag: EmotionTag; // 實際套用的表情(可能因親密度未解鎖而降級)
|
||||||
|
rawEmotionTag: EmotionTag; // 情緒引擎真實判定的狀態,不受親密度解鎖影響——立繪與文字可以故意不一致
|
||||||
|
eyebrow: string;
|
||||||
|
eye: string;
|
||||||
|
mouth: string;
|
||||||
|
effect: string | null;
|
||||||
|
pose: string;
|
||||||
|
magnitude: number; // O-4:0~1,外顯度 × 情緒強度——同樣是愉悅,元氣角色滿臉笑容、三無角色只有嘴角微動
|
||||||
|
gaze: GazeDirection;
|
||||||
|
availableCostumes: string[];
|
||||||
|
lockedFallbackApplied: boolean; // O-3:真實情緒不在目前親密度階段的解鎖範圍內,被降級成基本表情
|
||||||
|
usedCryingVariant: boolean; // O-3:哭臉——只有 BONDED 階段的深度低落才會出現
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldFor(tag: EmotionTag): keyof Omit<EmotionState, "characterId" | "updatedAt"> {
|
||||||
|
return tag === "CALM" ? "calm" : (tag.toLowerCase() as "joy" | "sad" | "alert" | "shy" | "grumpy");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(value: number): number {
|
||||||
|
return Math.max(0, Math.min(1, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGaze(emotionTag: EmotionTag, options: ResolveExpressionOptions): GazeDirection {
|
||||||
|
if (options.isThinking) {
|
||||||
|
return "UP";
|
||||||
|
}
|
||||||
|
if (options.isDeflecting || emotionTag === "SHY") {
|
||||||
|
return "AWAY";
|
||||||
|
}
|
||||||
|
if (emotionTag === "ALERT") {
|
||||||
|
return "DIRECT";
|
||||||
|
}
|
||||||
|
return "NEUTRAL";
|
||||||
|
}
|
||||||
|
|
||||||
|
// O-2/O-3/O-4 表情解析:情緒引擎直接驅動表情差分,套用親密度解鎖與性格外顯度縮放。
|
||||||
|
// 這裡回傳的「表情」永遠反映真實情緒狀態(rawEmotionTag/magnitude 都不看對話文字內容),
|
||||||
|
// 文字生成走另一條完全獨立的路徑(MockProvider)——兩者不一致本身就是「微反差演出」,
|
||||||
|
// 不需要額外寫「故意不一致」的特殊邏輯,是兩條獨立資料流的自然結果。
|
||||||
|
@Injectable()
|
||||||
|
export class ExpressionResolverService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly emotion: EmotionService,
|
||||||
|
private readonly relationship: RelationshipService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async resolveExpression(
|
||||||
|
characterId: string,
|
||||||
|
userId: string,
|
||||||
|
options: ResolveExpressionOptions = {},
|
||||||
|
): Promise<ExpressionState> {
|
||||||
|
const now = options.now ?? new Date();
|
||||||
|
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||||
|
const archetypeParams = getArchetypeParams(character.personalityArchetype);
|
||||||
|
|
||||||
|
const emotionState = await this.emotion.getState(characterId, now);
|
||||||
|
const rawEmotionTag = dominantState(emotionState);
|
||||||
|
const intensityNormalized = clamp01((emotionState[fieldFor(rawEmotionTag)] as number) / 100);
|
||||||
|
|
||||||
|
const { relationship } = await this.relationship.getState(characterId, userId, now);
|
||||||
|
const stage = relationship.stage;
|
||||||
|
|
||||||
|
const allowedEmotions =
|
||||||
|
stage === "BONDED" || stage === "CLOSE_AMBIGUOUS"
|
||||||
|
? CLOSE_TIER_EMOTIONS
|
||||||
|
: stage === "FRIEND"
|
||||||
|
? FRIEND_TIER_EMOTIONS
|
||||||
|
: BASIC_TIER_EMOTIONS;
|
||||||
|
|
||||||
|
const lockedFallbackApplied = !allowedEmotions.includes(rawEmotionTag);
|
||||||
|
const emotionTag = lockedFallbackApplied ? "CALM" : rawEmotionTag;
|
||||||
|
const template = EMOTION_EXPRESSION_TABLE[emotionTag];
|
||||||
|
|
||||||
|
let effect = template.effect;
|
||||||
|
let usedCryingVariant = false;
|
||||||
|
if (emotionTag === "SAD" && intensityNormalized >= CRYING_INTENSITY_THRESHOLD && stage === "BONDED") {
|
||||||
|
effect = CRYING_EFFECT;
|
||||||
|
usedCryingVariant = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const magnitude = clamp01(archetypeParams.expressiveness * intensityNormalized);
|
||||||
|
const gaze = resolveGaze(emotionTag, options);
|
||||||
|
const availableCostumes =
|
||||||
|
stage === "BONDED" || stage === "CLOSE_AMBIGUOUS" ? CLOSE_TIER_COSTUMES : stage === "FRIEND" ? FRIEND_TIER_COSTUMES : ["制服"];
|
||||||
|
|
||||||
|
return {
|
||||||
|
emotionTag,
|
||||||
|
rawEmotionTag,
|
||||||
|
eyebrow: template.eyebrow,
|
||||||
|
eye: template.eye,
|
||||||
|
mouth: template.mouth,
|
||||||
|
effect,
|
||||||
|
pose: template.pose,
|
||||||
|
magnitude,
|
||||||
|
gaze,
|
||||||
|
availableCostumes,
|
||||||
|
lockedFallbackApplied,
|
||||||
|
usedCryingVariant,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import {
|
||||||
|
BODY_POSE_OPTIONS,
|
||||||
|
COSTUME_DEFAULT_OPTIONS,
|
||||||
|
EYEBROW_OPTIONS,
|
||||||
|
EYE_OPTIONS,
|
||||||
|
MOUTH_OPTIONS,
|
||||||
|
EFFECT_OPTIONS,
|
||||||
|
MICRO_MOTIONS,
|
||||||
|
} from "./constants.js";
|
||||||
|
|
||||||
|
export interface TachieManifest {
|
||||||
|
characterId: string;
|
||||||
|
bodyPoses: readonly string[];
|
||||||
|
costumes: string[]; // 有 M-1 服裝目錄時優先使用那份(依角色而異),否則退回預設兩套
|
||||||
|
expressionParts: { eyebrows: readonly string[]; eyes: readonly string[]; mouths: readonly string[] };
|
||||||
|
effects: readonly string[];
|
||||||
|
microMotions: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// O-1 差分資產結構:定義立繪 manifest 與資產目錄規範。這裡只登錄「有哪些部件可用」,
|
||||||
|
// 不含實際美術檔案(美術資產尚未存在,前端目前用佔位色塊渲染)——manifest 的形狀就是未來接上
|
||||||
|
// 真實素材時,資產檔名/URL 要對齊的目錄結構。
|
||||||
|
@Injectable()
|
||||||
|
export class ManifestService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getManifest(characterId: string): Promise<TachieManifest> {
|
||||||
|
const catalogEntries = await this.prisma.client.costumeCatalogEntry.findMany({ where: { characterId } });
|
||||||
|
const costumes = catalogEntries.length > 0 ? [...new Set(catalogEntries.map((entry) => entry.label))] : [...COSTUME_DEFAULT_OPTIONS];
|
||||||
|
|
||||||
|
return {
|
||||||
|
characterId,
|
||||||
|
bodyPoses: BODY_POSE_OPTIONS,
|
||||||
|
costumes,
|
||||||
|
expressionParts: { eyebrows: EYEBROW_OPTIONS, eyes: EYE_OPTIONS, mouths: MOUTH_OPTIONS },
|
||||||
|
effects: EFFECT_OPTIONS,
|
||||||
|
microMotions: MICRO_MOTIONS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Controller, Get, Param, Query } from "@nestjs/common";
|
||||||
|
import { ManifestService } from "./manifest.service.js";
|
||||||
|
import { ExpressionResolverService } from "./expression-resolver.service.js";
|
||||||
|
|
||||||
|
@Controller("tachie")
|
||||||
|
export class TachieController {
|
||||||
|
constructor(
|
||||||
|
private readonly manifest: ManifestService,
|
||||||
|
private readonly expression: ExpressionResolverService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get(":characterId/manifest")
|
||||||
|
async getManifest(@Param("characterId") characterId: string) {
|
||||||
|
return this.manifest.getManifest(characterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":characterId/:userId/expression")
|
||||||
|
async getExpression(
|
||||||
|
@Param("characterId") characterId: string,
|
||||||
|
@Param("userId") userId: string,
|
||||||
|
@Query("now") now?: string,
|
||||||
|
@Query("isThinking") isThinking?: string,
|
||||||
|
@Query("isDeflecting") isDeflecting?: string,
|
||||||
|
) {
|
||||||
|
return this.expression.resolveExpression(characterId, userId, {
|
||||||
|
now: now ? new Date(now) : undefined,
|
||||||
|
isThinking: isThinking === "true",
|
||||||
|
isDeflecting: isDeflecting === "true",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||||
|
import { EmotionModule } from "../emotion/emotion.module.js";
|
||||||
|
import { RelationshipModule } from "../relationship/relationship.module.js";
|
||||||
|
import { ManifestService } from "./manifest.service.js";
|
||||||
|
import { ExpressionResolverService } from "./expression-resolver.service.js";
|
||||||
|
import { TachieController } from "./tachie.controller.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, EmotionModule, RelationshipModule],
|
||||||
|
controllers: [TachieController],
|
||||||
|
providers: [ManifestService, ExpressionResolverService],
|
||||||
|
exports: [ManifestService, ExpressionResolverService],
|
||||||
|
})
|
||||||
|
export class TachieModule {}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// J-5 任務/關係帳本事件:委託建立與完成都會寫入正向事件;失敗與被忽視則為負向事件
|
||||||
|
// (由 E-2 的負向偏誤機制放大,一次失約可以抵銷多次守信)。
|
||||||
|
export const TASK_LEDGER_EVENT = {
|
||||||
|
ACCEPTED: "TASK_ACCEPTED",
|
||||||
|
COMPLETED: "TASK_COMPLETED",
|
||||||
|
FAILED: "TASK_FAILED",
|
||||||
|
IGNORED: "TASK_IGNORED",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const TASK_LEDGER_WEIGHT = {
|
||||||
|
ACCEPTED: 1,
|
||||||
|
COMPLETED: 3,
|
||||||
|
FAILED: -3,
|
||||||
|
IGNORED: -1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// 同角色/同使用者/同類型/同內容的委託累積達到此次數(含本次),視為形成「默契」。
|
||||||
|
export const RAPPORT_THRESHOLD = 2;
|
||||||
|
|
||||||
|
// J-4 逾期追擊:觸發後多久沒有互動視為逾期,以及每次追擊的最短間隔。
|
||||||
|
export const OVERDUE_GRACE_MINUTES = 10;
|
||||||
|
export const OVERDUE_NUDGE_INTERVAL_MINUTES = 10;
|
||||||
|
export const OVERDUE_IGNORED_NUDGE_COUNT = 3; // 累積追擊次數達此值仍無回應 → 觸發「被忽視」的低落情緒
|
||||||
|
|
||||||
|
// J-6 主動關照:稀有度節流(同角色/同使用者的主動關照,冷卻期內不可再次觸發)。
|
||||||
|
export const PROACTIVE_CARE_COOLDOWN_HOURS = 12;
|
||||||
|
export const TODO_SIGNAL_KEYWORDS = ["要交", "報告", "考試", "作業", "還沒做", "要完成", "截止"];
|
||||||
|
export const LATE_NIGHT_PATTERN_KEYWORDS = ["熬夜", "還沒睡", "沒睡"];
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||||
|
import { proactiveCareLine, bedtimeCareLine } from "./task-performance.js";
|
||||||
|
import { PROACTIVE_CARE_COOLDOWN_HOURS, TODO_SIGNAL_KEYWORDS, LATE_NIGHT_PATTERN_KEYWORDS } from "./constants.js";
|
||||||
|
|
||||||
|
const HOUR_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// J-6 主動關照:從記憶偵測使用者提過的待辦、交叉比對作息型態,主動關心——但必須稀有且不重複台詞。
|
||||||
|
@Injectable()
|
||||||
|
export class ProactiveCareService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly schedule: ScheduleService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async maybeGenerate(characterId: string, userId: string, now: Date = new Date()): Promise<string | null> {
|
||||||
|
const lastLog = await this.prisma.client.proactiveCareLog.findFirst({
|
||||||
|
where: { characterId, userId },
|
||||||
|
orderBy: { triggeredAt: "desc" },
|
||||||
|
});
|
||||||
|
if (lastLog && now.getTime() - lastLog.triggeredAt.getTime() < PROACTIVE_CARE_COOLDOWN_HOURS * HOUR_MS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||||
|
const priorLogs = await this.prisma.client.proactiveCareLog.findMany({ where: { characterId, userId } });
|
||||||
|
const usedMessages = new Set(priorLogs.map((log) => log.message));
|
||||||
|
|
||||||
|
const todoMemories = await this.prisma.client.episodicMemory.findMany({
|
||||||
|
where: {
|
||||||
|
characterId,
|
||||||
|
relatedUserId: userId,
|
||||||
|
OR: TODO_SIGNAL_KEYWORDS.map((keyword) => ({ content: { contains: keyword } })),
|
||||||
|
},
|
||||||
|
orderBy: { occurredAt: "desc" },
|
||||||
|
});
|
||||||
|
for (const memory of todoMemories) {
|
||||||
|
const line = proactiveCareLine(character.personalityArchetype, memory.content);
|
||||||
|
if (!usedMessages.has(line)) {
|
||||||
|
await this.log(characterId, userId, line, now);
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交叉比對作息型態:使用者常提到熬夜,且目前正值睡前時段 → 主動叮嚀早點休息。
|
||||||
|
const info = await this.schedule.getInfo(characterId, now);
|
||||||
|
if (info.isPreSleep) {
|
||||||
|
const lateNightMemory = await this.prisma.client.episodicMemory.findFirst({
|
||||||
|
where: {
|
||||||
|
characterId,
|
||||||
|
relatedUserId: userId,
|
||||||
|
OR: LATE_NIGHT_PATTERN_KEYWORDS.map((keyword) => ({ content: { contains: keyword } })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (lateNightMemory) {
|
||||||
|
const line = bedtimeCareLine(character.personalityArchetype);
|
||||||
|
if (!usedMessages.has(line)) {
|
||||||
|
await this.log(characterId, userId, line, now);
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async log(characterId: string, userId: string, message: string, now: Date): Promise<void> {
|
||||||
|
await this.prisma.client.proactiveCareLog.create({ data: { characterId, userId, message, triggeredAt: now } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { SentimentLedgerService } from "../relationship/sentiment-ledger.service.js";
|
||||||
|
import { EmotionService } from "../emotion/emotion.service.js";
|
||||||
|
import { TASK_LEDGER_EVENT, TASK_LEDGER_WEIGHT } from "./constants.js";
|
||||||
|
|
||||||
|
// J-5 任務與記憶、關係的迴路:完成/失敗誠實回報(引擎層),但角色演出可以是遺憾/得意的語氣。
|
||||||
|
@Injectable()
|
||||||
|
export class TaskFeedbackService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly sentimentLedger: SentimentLedgerService,
|
||||||
|
private readonly emotion: EmotionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async complete(taskId: string, now: Date = new Date()) {
|
||||||
|
const task = await this.prisma.client.task.findUnique({ where: { id: taskId } });
|
||||||
|
if (!task) {
|
||||||
|
throw new NotFoundException(`任務不存在:${taskId}`);
|
||||||
|
}
|
||||||
|
await this.prisma.client.task.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: { status: "COMPLETED", completedAt: now },
|
||||||
|
});
|
||||||
|
await this.sentimentLedger.recordEvent(
|
||||||
|
task.characterId,
|
||||||
|
task.userId,
|
||||||
|
TASK_LEDGER_EVENT.COMPLETED,
|
||||||
|
TASK_LEDGER_WEIGHT.COMPLETED,
|
||||||
|
{ now },
|
||||||
|
);
|
||||||
|
return this.prisma.client.task.findUnique({ where: { id: taskId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async fail(taskId: string, now: Date = new Date()) {
|
||||||
|
const task = await this.prisma.client.task.findUnique({ where: { id: taskId } });
|
||||||
|
if (!task) {
|
||||||
|
throw new NotFoundException(`任務不存在:${taskId}`);
|
||||||
|
}
|
||||||
|
await this.prisma.client.task.update({ where: { id: taskId }, data: { status: "FAILED" } });
|
||||||
|
// 誠實回報失敗(引擎層行為固定),負向偏誤已由 E-2 機制放大,不需要在這裡重複加權。
|
||||||
|
await this.sentimentLedger.recordEvent(
|
||||||
|
task.characterId,
|
||||||
|
task.userId,
|
||||||
|
TASK_LEDGER_EVENT.FAILED,
|
||||||
|
TASK_LEDGER_WEIGHT.FAILED,
|
||||||
|
{ now },
|
||||||
|
);
|
||||||
|
return this.prisma.client.task.findUnique({ where: { id: taskId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 觸發後被忽視(追擊次數已達門檻仍無回應):輕微失落,且如實記錄一筆負向事件。
|
||||||
|
async markIgnored(taskId: string, now: Date = new Date()) {
|
||||||
|
const task = await this.prisma.client.task.findUnique({ where: { id: taskId } });
|
||||||
|
if (!task) {
|
||||||
|
throw new NotFoundException(`任務不存在:${taskId}`);
|
||||||
|
}
|
||||||
|
await this.sentimentLedger.recordEvent(
|
||||||
|
task.characterId,
|
||||||
|
task.userId,
|
||||||
|
TASK_LEDGER_EVENT.IGNORED,
|
||||||
|
TASK_LEDGER_WEIGHT.IGNORED,
|
||||||
|
{ now },
|
||||||
|
);
|
||||||
|
await this.emotion.processInput(task.characterId, "", {
|
||||||
|
now,
|
||||||
|
baselineSignal: { tag: "SAD", intensity: 0.3 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { TaskFeedbackService } from "./task-feedback.service.js";
|
||||||
|
import { overdueNudgeLine } from "./task-performance.js";
|
||||||
|
import { OVERDUE_GRACE_MINUTES, OVERDUE_NUDGE_INTERVAL_MINUTES, OVERDUE_IGNORED_NUDGE_COUNT } from "./constants.js";
|
||||||
|
|
||||||
|
const MINUTE_MS = 60 * 1000;
|
||||||
|
|
||||||
|
// J-4 逾期追擊:已觸發但遲遲沒有下文的委託,依原型節奏產生追擊訊息;追擊到一定次數仍無回應
|
||||||
|
// 視為「被忽視」,交給 J-5 的回饋迴路記一筆輕微失落。
|
||||||
|
@Injectable()
|
||||||
|
export class TaskOverdueService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly feedback: TaskFeedbackService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async checkAndNudge(taskId: string, now: Date = new Date()): Promise<string | null> {
|
||||||
|
const task = await this.prisma.client.task.findUnique({ where: { id: taskId } });
|
||||||
|
if (!task || task.status !== "TRIGGERED" || !task.triggeredAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const since = task.lastNudgeAt ?? task.triggeredAt;
|
||||||
|
const graceMs =
|
||||||
|
(task.lastNudgeAt ? OVERDUE_NUDGE_INTERVAL_MINUTES : OVERDUE_GRACE_MINUTES) * MINUTE_MS;
|
||||||
|
if (now.getTime() - since.getTime() < graceMs) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: task.characterId } });
|
||||||
|
const nudgeIndex = task.overdueNudgeCount;
|
||||||
|
const message = overdueNudgeLine(character.personalityArchetype, nudgeIndex);
|
||||||
|
|
||||||
|
await this.prisma.client.task.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: { overdueNudgeCount: { increment: 1 }, lastNudgeAt: now },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nudgeIndex + 1 >= OVERDUE_IGNORED_NUDGE_COUNT) {
|
||||||
|
await this.feedback.markIgnored(taskId, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { taipeiParts } from "../schedule/time-utils.js";
|
||||||
|
|
||||||
|
export interface ParsedReminderRequest {
|
||||||
|
content: string; // 去除時間片語與「提醒我」之後的委託內容
|
||||||
|
triggerAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// J-1 自然語句解析:中文數字/阿拉伯數字時刻皆可辨識。
|
||||||
|
const CHINESE_HOUR_WORDS: [string, number][] = [
|
||||||
|
["十二", 12],
|
||||||
|
["十一", 11],
|
||||||
|
["十", 10],
|
||||||
|
["九", 9],
|
||||||
|
["八", 8],
|
||||||
|
["七", 7],
|
||||||
|
["六", 6],
|
||||||
|
["五", 5],
|
||||||
|
["四", 4],
|
||||||
|
["三", 3],
|
||||||
|
["兩", 2],
|
||||||
|
["二", 2],
|
||||||
|
["一", 1],
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseHour(segment: string): number | null {
|
||||||
|
const arabic = segment.match(/(\d{1,2})\s*[點点:時]/);
|
||||||
|
if (arabic) {
|
||||||
|
return Number(arabic[1]);
|
||||||
|
}
|
||||||
|
for (const [word, value] of CHINESE_HOUR_WORDS) {
|
||||||
|
if (segment.includes(`${word}點`) || segment.includes(`${word}点`)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMinute(segment: string): number {
|
||||||
|
if (segment.includes("半")) {
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
const explicit = segment.match(/[點点](\d{1,2})分/);
|
||||||
|
if (explicit) {
|
||||||
|
return Number(explicit[1]);
|
||||||
|
}
|
||||||
|
const colon = segment.match(/:(\d{1,2})/);
|
||||||
|
if (colon) {
|
||||||
|
return Number(colon[1]);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 依 24 小時制/上下午標記/模糊小時(1~12,取最接近的未來時刻)解析出候選小時(依時間先後排序)。
|
||||||
|
function resolveHourCandidates(segment: string, hour: number): number[] {
|
||||||
|
const isPM = /下午|晚上|傍晚|中午(?!.*上午)/.test(segment) && hour !== 12;
|
||||||
|
const isAM = /早上|上午|凌晨/.test(segment);
|
||||||
|
if (isPM) {
|
||||||
|
return [hour === 12 ? 12 : hour + 12];
|
||||||
|
}
|
||||||
|
if (isAM) {
|
||||||
|
return [hour === 12 ? 0 : hour];
|
||||||
|
}
|
||||||
|
if (hour >= 13 || hour === 0) {
|
||||||
|
return [hour];
|
||||||
|
}
|
||||||
|
if (hour === 12) {
|
||||||
|
return [12, 0];
|
||||||
|
}
|
||||||
|
return [hour, hour + 12];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTaipeiDateTime(now: Date, hour: number, minute: number): Date {
|
||||||
|
const { year, month, day } = taipeiParts(now);
|
||||||
|
// Asia/Taipei 為 UTC+8:當地 HH:MM 對應 UTC (HH-8):MM(JS Date.UTC 會自動處理跨日進位/退位)。
|
||||||
|
return new Date(Date.UTC(year, month - 1, day, hour - 8, minute, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestFutureOccurrence(now: Date, candidateHours: number[], minute: number): Date {
|
||||||
|
for (const hour of candidateHours) {
|
||||||
|
const candidate = buildTaipeiDateTime(now, hour, minute);
|
||||||
|
if (candidate.getTime() > now.getTime()) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fallback = buildTaipeiDateTime(now, candidateHours[0], minute);
|
||||||
|
return new Date(fallback.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析「單次提醒」型委託,例如「三點提醒我開會」「晚上八點半提醒我倒垃圾」。
|
||||||
|
// 無法辨識時間片語時回傳 null(呼叫端可視為一般對話,不建立任務)。
|
||||||
|
export function parseReminderRequest(text: string, now: Date = new Date()): ParsedReminderRequest | null {
|
||||||
|
const match = text.match(/^(.*?)提醒我(.+)$/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const [, timeSegment, rawContent] = match;
|
||||||
|
|
||||||
|
const hour = parseHour(timeSegment);
|
||||||
|
if (hour === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const minute = parseMinute(timeSegment);
|
||||||
|
const candidates = resolveHourCandidates(timeSegment, hour);
|
||||||
|
const triggerAt = nearestFutureOccurrence(now, candidates, minute);
|
||||||
|
|
||||||
|
const content = rawContent.trim().replace(/^[,,、]/, "").trim();
|
||||||
|
if (!content) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content, triggerAt };
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { Archetype } from "../personality/archetype-params.js";
|
||||||
|
|
||||||
|
// J-2 接案演出:無論表面台詞是拒絕還是嫌麻煩,引擎層都一定會實際建立任務(見 TaskService.createFromText)。
|
||||||
|
const ACCEPTANCE_LINES: Record<Archetype, string[]> = {
|
||||||
|
傲嬌: ["哈?我才不是你的秘書咧……真是的,這次就特別幫你記著", "誰、誰要幫你了!……我記下了啦,別誤會"],
|
||||||
|
冷淡: ["……嗯,記住了", "……知道了"],
|
||||||
|
天然呆: ["咦?好,我盡量記得的說!", "嗯嗯,交給我吧的說"],
|
||||||
|
元氣: ["交給我吧!包在我身上!", "沒問題!我一定會提醒你的!"],
|
||||||
|
大小姐: ["真拿你沒辦法呢,本小姐就大發慈悲幫你這一次", "哼,算你走運,我記下了"],
|
||||||
|
三無: ["……嗯", "…知道了"],
|
||||||
|
};
|
||||||
|
const DEFAULT_ACCEPTANCE_LINES = ["好,我記下了"];
|
||||||
|
|
||||||
|
export function acceptanceLine(archetype: string, index = 0): string {
|
||||||
|
const lines = ACCEPTANCE_LINES[archetype as Archetype] ?? DEFAULT_ACCEPTANCE_LINES;
|
||||||
|
return lines[index % lines.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
// J-3 觸發演出:語氣依時段調整(深夜安靜、剛睡醒迷糊但仍準時),內容盡量帶出與此委託相關的記憶回顧。
|
||||||
|
export function triggerTone(archetype: string, isLateNight: boolean, isWaking: boolean): string {
|
||||||
|
if (isLateNight) {
|
||||||
|
const quiet: Record<Archetype, string> = {
|
||||||
|
傲嬌: "……喂,還醒著吧?",
|
||||||
|
冷淡: "……時間到了",
|
||||||
|
天然呆: "……欸嘿,還沒睡嗎的說?",
|
||||||
|
元氣: "(小聲)……喂,時間到囉",
|
||||||
|
大小姐: "……這麼晚了,你還在吧?",
|
||||||
|
三無: "……",
|
||||||
|
};
|
||||||
|
return quiet[archetype as Archetype] ?? "……時間到了";
|
||||||
|
}
|
||||||
|
if (isWaking) {
|
||||||
|
const groggy: Record<Archetype, string> = {
|
||||||
|
傲嬌: "……唔,剛醒就要唸這個,真是的——",
|
||||||
|
冷淡: "……早,時間到了",
|
||||||
|
天然呆: "……早安的說,欸?已經這個時間了嗎",
|
||||||
|
元氣: "早安!雖然還有點想睡,但時間到囉!",
|
||||||
|
大小姐: "……早安,本小姐可是準時的",
|
||||||
|
三無: "……早",
|
||||||
|
};
|
||||||
|
return groggy[archetype as Archetype] ?? "……時間到了";
|
||||||
|
}
|
||||||
|
const normal: Record<Archetype, string> = {
|
||||||
|
傲嬌: "喂,時間到了啦,還不快去",
|
||||||
|
冷淡: "時間到了",
|
||||||
|
天然呆: "時間到囉的說!",
|
||||||
|
元氣: "時間到囉!說好的事情不能忘記喔!",
|
||||||
|
大小姐: "時間到了,本小姐可沒忘記",
|
||||||
|
三無: "……時間到了",
|
||||||
|
};
|
||||||
|
return normal[archetype as Archetype] ?? "時間到了";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTriggerMessage(
|
||||||
|
archetype: string,
|
||||||
|
content: string,
|
||||||
|
isLateNight: boolean,
|
||||||
|
isWaking: boolean,
|
||||||
|
memoryCallback: string | null,
|
||||||
|
): string {
|
||||||
|
const tone = triggerTone(archetype, isLateNight, isWaking);
|
||||||
|
const reminder = `${tone}——「${content}」`;
|
||||||
|
return memoryCallback ? `${reminder}(${memoryCallback})` : reminder;
|
||||||
|
}
|
||||||
|
|
||||||
|
// J-4 逾期追擊:原型決定追擊節奏與嘴硬程度,但引擎層仍會確實記錄追擊次數。
|
||||||
|
const OVERDUE_NUDGES: Record<Archetype, string[]> = {
|
||||||
|
傲嬌: ["喂!你是不是又忘記了!……哼,我才没有担心你", "……算了,反正你也不會理我", "真是的,講都講不聽"],
|
||||||
|
冷淡: ["……還沒做嗎", "……"],
|
||||||
|
天然呆: ["咦,還沒做完嗎的說?要不要我再提醒一次?", "……欸嘿,是不是忘記了的說"],
|
||||||
|
元氣: ["喂喂喂!還沒做嗎!快點快點!", "欸~你是不是忘記了!我都要開始擔心了!", "拜託拜託,快去做啦!"],
|
||||||
|
大小姐: ["哼,本小姐都提醒過了,你還敢忘記", "真是不像話,還不快去"],
|
||||||
|
三無: ["……", "…還沒"],
|
||||||
|
};
|
||||||
|
const DEFAULT_OVERDUE_NUDGES = ["還記得剛剛提醒你的事嗎?"];
|
||||||
|
|
||||||
|
export function overdueNudgeLine(archetype: string, nudgeIndex: number): string {
|
||||||
|
const lines = OVERDUE_NUDGES[archetype as Archetype] ?? DEFAULT_OVERDUE_NUDGES;
|
||||||
|
return lines[Math.min(nudgeIndex, lines.length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// J-6 主動關照:從記憶偵測使用者提過的待辦事項,主動問候進度。
|
||||||
|
export function proactiveCareLine(archetype: string, memoryContent: string): string {
|
||||||
|
const templates: Record<Archetype, string> = {
|
||||||
|
傲嬌: `之前你不是說「${memoryContent}」……我才不是關心你,只是剛好想到而已`,
|
||||||
|
冷淡: `……「${memoryContent}」,處理了嗎`,
|
||||||
|
天然呆: `欸,你之前說的「${memoryContent}」,還好嗎的說?`,
|
||||||
|
元氣: `對了!你之前說「${memoryContent}」,進度怎麼樣了!`,
|
||||||
|
大小姐: `本小姐記得你提過「${memoryContent}」,進展如何?`,
|
||||||
|
三無: `……「${memoryContent}」……還好嗎`,
|
||||||
|
};
|
||||||
|
return templates[archetype as Archetype] ?? `之前你提過「${memoryContent}」,還順利嗎?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// J-6 交叉比對作息型態:偵測到使用者常態性熬夜,睡前時段主動關心。
|
||||||
|
export function bedtimeCareLine(archetype: string): string {
|
||||||
|
const templates: Record<Archetype, string> = {
|
||||||
|
傲嬌: "喂,你又要熬夜了吧……早點睡啦,笨蛋",
|
||||||
|
冷淡: "……早點睡",
|
||||||
|
天然呆: "欸,這麼晚了還不睡的說?",
|
||||||
|
元氣: "欸~已經很晚囉!不可以又熬夜喔!",
|
||||||
|
大小姐: "這個時間了,本小姐命令你去睡覺",
|
||||||
|
三無: "……該睡了",
|
||||||
|
};
|
||||||
|
return templates[archetype as Archetype] ?? "早點休息吧";
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Inject, Injectable, OnModuleInit } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { JOB_QUEUE, type JobQueue } from "../memory/job-queue.js";
|
||||||
|
import { TaskTriggerService } from "./task-trigger.service.js";
|
||||||
|
|
||||||
|
// J-7 排程器可靠性:InProcessJobQueue 的計時器只存在於記憶體中,重啟後全部消失。
|
||||||
|
// 因此每次啟動都要重新掃描資料庫裡「尚未觸發」的任務並掛回排程,逾期未觸發的會立刻補觸發。
|
||||||
|
@Injectable()
|
||||||
|
export class TaskSchedulerService implements OnModuleInit {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
@Inject(JOB_QUEUE) private readonly jobQueue: JobQueue,
|
||||||
|
private readonly trigger: TaskTriggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
const pending = await this.prisma.client.task.findMany({
|
||||||
|
where: { status: "PENDING", triggerAt: { not: null } },
|
||||||
|
});
|
||||||
|
for (const task of pending) {
|
||||||
|
this.scheduleTask(task.id, task.triggerAt!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleTask(taskId: string, triggerAt: Date): void {
|
||||||
|
this.jobQueue.schedule(triggerAt, `task:${taskId}`, () => {
|
||||||
|
this.trigger.fire(taskId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||||
|
import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js";
|
||||||
|
import { PushNotificationService } from "../push/push-notification.service.js";
|
||||||
|
import { taipeiParts } from "../schedule/time-utils.js";
|
||||||
|
import { buildTriggerMessage } from "./task-performance.js";
|
||||||
|
|
||||||
|
// J-3 觸發演出:任務時間到時,依作息語氣+相關記憶回顧組成訊息;準時觸發是引擎責任,語氣是角色責任。
|
||||||
|
// Q-3:觸發的同一刻也會嘗試推播給使用者的裝置(提醒、她的主動訊息都走這條路)——沒有註冊過
|
||||||
|
// 任何裝置 token 時,PushNotificationService.sendToUser 單純回報 attempted=0,不影響任務本身的觸發。
|
||||||
|
@Injectable()
|
||||||
|
export class TaskTriggerService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly schedule: ScheduleService,
|
||||||
|
private readonly memoryRetriever: KeywordMemoryRetriever,
|
||||||
|
private readonly pushNotification: PushNotificationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async fire(taskId: string, now: Date = new Date()): Promise<string | null> {
|
||||||
|
const task = await this.prisma.client.task.findUnique({ where: { id: taskId } });
|
||||||
|
// 服務重啟時舊的 setTimeout 可能與新掛回的排程重複註冊;狀態不是 PENDING 就不再重複觸發。
|
||||||
|
if (!task || task.status !== "PENDING") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: task.characterId } });
|
||||||
|
const info = await this.schedule.getInfo(task.characterId, now);
|
||||||
|
const { hour } = taipeiParts(now);
|
||||||
|
const isLateNight = info.slot?.status === "SLEEP" || hour < 6 || hour >= 23;
|
||||||
|
|
||||||
|
const memories = await this.memoryRetriever.retrieve(task.characterId, task.content, { limit: 1 });
|
||||||
|
const topMemory = memories[0];
|
||||||
|
const memoryCallback =
|
||||||
|
topMemory && task.content.length > 0 && topMemory.content.includes(task.content)
|
||||||
|
? `記得你之前提過:「${topMemory.content}」`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const message = buildTriggerMessage(
|
||||||
|
character.personalityArchetype,
|
||||||
|
task.content,
|
||||||
|
isLateNight,
|
||||||
|
info.isWaking,
|
||||||
|
memoryCallback,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.prisma.client.task.update({
|
||||||
|
where: { id: task.id },
|
||||||
|
data: { status: "TRIGGERED", triggeredAt: now, triggerMessage: message },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.pushNotification.sendToUser(task.userId, "心音 Kokorone", message);
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||||
|
import { TaskService } from "./task.service.js";
|
||||||
|
import { TaskTriggerService } from "./task-trigger.service.js";
|
||||||
|
import { TaskOverdueService } from "./task-overdue.service.js";
|
||||||
|
import { TaskFeedbackService } from "./task-feedback.service.js";
|
||||||
|
import { ProactiveCareService } from "./proactive-care.service.js";
|
||||||
|
|
||||||
|
interface ParseBody {
|
||||||
|
userId: string;
|
||||||
|
text: string;
|
||||||
|
now?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateBody {
|
||||||
|
userId: string;
|
||||||
|
type: "ONE_TIME" | "RECURRING" | "CONDITIONAL" | "QUERY_ORGANIZE" | "TODO_TRACKING";
|
||||||
|
content: string;
|
||||||
|
rawText: string;
|
||||||
|
triggerAt?: string;
|
||||||
|
cronExpression?: string;
|
||||||
|
condition?: string;
|
||||||
|
now?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller("task")
|
||||||
|
export class TaskController {
|
||||||
|
constructor(
|
||||||
|
private readonly taskService: TaskService,
|
||||||
|
private readonly trigger: TaskTriggerService,
|
||||||
|
private readonly overdue: TaskOverdueService,
|
||||||
|
private readonly feedback: TaskFeedbackService,
|
||||||
|
private readonly proactiveCare: ProactiveCareService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get(":taskId")
|
||||||
|
async getTask(@Param("taskId") taskId: string) {
|
||||||
|
return this.taskService.get(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list(@Query("characterId") characterId: string, @Query("userId") userId: string) {
|
||||||
|
return this.taskService.listForUser(characterId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// J-1/J-2:嘗試把自然語句解析為單次提醒委託;無法辨識時 created=false(呼叫端應走一般對話)。
|
||||||
|
@Post(":characterId/parse")
|
||||||
|
async parse(@Param("characterId") characterId: string, @Body() body: ParseBody) {
|
||||||
|
const now = body.now ? new Date(body.now) : undefined;
|
||||||
|
const result = await this.taskService.tryCreateFromText(characterId, body.userId, body.text, now);
|
||||||
|
if (!result) {
|
||||||
|
return { created: false };
|
||||||
|
}
|
||||||
|
return { created: true, ...result };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":characterId")
|
||||||
|
async create(@Param("characterId") characterId: string, @Body() body: CreateBody) {
|
||||||
|
const now = body.now ? new Date(body.now) : undefined;
|
||||||
|
const result = await this.taskService.create(
|
||||||
|
characterId,
|
||||||
|
body.userId,
|
||||||
|
{
|
||||||
|
type: body.type,
|
||||||
|
content: body.content,
|
||||||
|
rawText: body.rawText,
|
||||||
|
triggerAt: body.triggerAt ? new Date(body.triggerAt) : undefined,
|
||||||
|
cronExpression: body.cronExpression,
|
||||||
|
condition: body.condition,
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 測試用途:直接觸發(略過真實排程等待),驗證 J-3 觸發演出的內容邏輯。
|
||||||
|
@Post(":taskId/fire-now")
|
||||||
|
async fireNow(@Param("taskId") taskId: string, @Body() body: { now?: string }) {
|
||||||
|
const message = await this.trigger.fire(taskId, body.now ? new Date(body.now) : undefined);
|
||||||
|
return { message };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":taskId/check-overdue")
|
||||||
|
async checkOverdue(@Param("taskId") taskId: string, @Body() body: { now?: string }) {
|
||||||
|
const message = await this.overdue.checkAndNudge(taskId, body.now ? new Date(body.now) : undefined);
|
||||||
|
return { message };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":taskId/complete")
|
||||||
|
async complete(@Param("taskId") taskId: string, @Body() body: { now?: string }) {
|
||||||
|
return this.feedback.complete(taskId, body.now ? new Date(body.now) : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":taskId/fail")
|
||||||
|
async fail(@Param("taskId") taskId: string, @Body() body: { now?: string }) {
|
||||||
|
return this.feedback.fail(taskId, body.now ? new Date(body.now) : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("proactive-care/:characterId")
|
||||||
|
async proactive(@Param("characterId") characterId: string, @Body() body: { userId: string; now?: string }) {
|
||||||
|
const message = await this.proactiveCare.maybeGenerate(characterId, body.userId, body.now ? new Date(body.now) : undefined);
|
||||||
|
return { message };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||||
|
import { MemoryModule } from "../memory/memory.module.js";
|
||||||
|
import { RelationshipModule } from "../relationship/relationship.module.js";
|
||||||
|
import { EmotionModule } from "../emotion/emotion.module.js";
|
||||||
|
import { ScheduleModule } from "../schedule/schedule.module.js";
|
||||||
|
import { PushModule } from "../push/push.module.js";
|
||||||
|
import { TaskService } from "./task.service.js";
|
||||||
|
import { TaskSchedulerService } from "./task-scheduler.service.js";
|
||||||
|
import { TaskTriggerService } from "./task-trigger.service.js";
|
||||||
|
import { TaskOverdueService } from "./task-overdue.service.js";
|
||||||
|
import { TaskFeedbackService } from "./task-feedback.service.js";
|
||||||
|
import { ProactiveCareService } from "./proactive-care.service.js";
|
||||||
|
import { TaskController } from "./task.controller.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, MemoryModule, RelationshipModule, EmotionModule, ScheduleModule, PushModule],
|
||||||
|
controllers: [TaskController],
|
||||||
|
providers: [
|
||||||
|
TaskService,
|
||||||
|
TaskSchedulerService,
|
||||||
|
TaskTriggerService,
|
||||||
|
TaskOverdueService,
|
||||||
|
TaskFeedbackService,
|
||||||
|
ProactiveCareService,
|
||||||
|
],
|
||||||
|
exports: [TaskService, TaskSchedulerService, TaskTriggerService, TaskOverdueService, TaskFeedbackService, ProactiveCareService],
|
||||||
|
})
|
||||||
|
export class TaskModule {}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { SentimentLedgerService } from "../relationship/sentiment-ledger.service.js";
|
||||||
|
import { TaskSchedulerService } from "./task-scheduler.service.js";
|
||||||
|
import { parseReminderRequest } from "./task-parser.js";
|
||||||
|
import { acceptanceLine } from "./task-performance.js";
|
||||||
|
import { TASK_LEDGER_EVENT, TASK_LEDGER_WEIGHT, RAPPORT_THRESHOLD } from "./constants.js";
|
||||||
|
|
||||||
|
export interface CreateTaskResult {
|
||||||
|
task: Awaited<ReturnType<PrismaService["client"]["task"]["create"]>>;
|
||||||
|
performanceLine: string;
|
||||||
|
rapport: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// J-1/J-2/J-5 委託子系統核心:解析自然語句、實際建立任務(引擎責任),並附上接案演出(角色責任)。
|
||||||
|
@Injectable()
|
||||||
|
export class TaskService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly sentimentLedger: SentimentLedgerService,
|
||||||
|
private readonly scheduler: TaskSchedulerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// 嘗試把使用者輸入解析為單次提醒委託;無法辨識時回傳 null(呼叫端應視為一般對話)。
|
||||||
|
async tryCreateFromText(
|
||||||
|
characterId: string,
|
||||||
|
userId: string,
|
||||||
|
text: string,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): Promise<CreateTaskResult | null> {
|
||||||
|
const parsed = parseReminderRequest(text, now);
|
||||||
|
if (!parsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.create(
|
||||||
|
characterId,
|
||||||
|
userId,
|
||||||
|
{ type: "ONE_TIME", content: parsed.content, rawText: text, triggerAt: parsed.triggerAt },
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
characterId: string,
|
||||||
|
userId: string,
|
||||||
|
input: { type: "ONE_TIME" | "RECURRING" | "CONDITIONAL" | "QUERY_ORGANIZE" | "TODO_TRACKING"; content: string; rawText: string; triggerAt?: Date; cronExpression?: string; condition?: string },
|
||||||
|
now: Date = new Date(),
|
||||||
|
): Promise<CreateTaskResult> {
|
||||||
|
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||||
|
|
||||||
|
// J-5 默契旗標:同角色/同使用者/同類型/同內容的委託,這是第幾次了。
|
||||||
|
const priorCount = await this.prisma.client.task.count({
|
||||||
|
where: { characterId, userId, type: input.type, content: input.content },
|
||||||
|
});
|
||||||
|
const rapport = priorCount + 1 >= RAPPORT_THRESHOLD;
|
||||||
|
|
||||||
|
const task = await this.prisma.client.task.create({
|
||||||
|
data: {
|
||||||
|
characterId,
|
||||||
|
userId,
|
||||||
|
type: input.type,
|
||||||
|
content: input.content,
|
||||||
|
rawText: input.rawText,
|
||||||
|
triggerAt: input.triggerAt,
|
||||||
|
cronExpression: input.cronExpression,
|
||||||
|
condition: input.condition,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (task.triggerAt) {
|
||||||
|
this.scheduler.scheduleTask(task.id, task.triggerAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// J-5 任務與關係的迴路:委託本身(接案)就寫入正向關係帳本事件,不必等到完成。
|
||||||
|
await this.sentimentLedger.recordEvent(
|
||||||
|
characterId,
|
||||||
|
userId,
|
||||||
|
TASK_LEDGER_EVENT.ACCEPTED,
|
||||||
|
TASK_LEDGER_WEIGHT.ACCEPTED,
|
||||||
|
{ now },
|
||||||
|
);
|
||||||
|
|
||||||
|
const performanceLine = acceptanceLine(character.personalityArchetype, priorCount);
|
||||||
|
return { task, performanceLine, rapport };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(taskId: string) {
|
||||||
|
return this.prisma.client.task.findUnique({ where: { id: taskId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async listForUser(characterId: string, userId: string) {
|
||||||
|
return this.prisma.client.task.findMany({
|
||||||
|
where: { characterId, userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(taskId: string) {
|
||||||
|
return this.prisma.client.task.update({ where: { id: taskId }, data: { status: "CANCELLED" } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { EmotionTag } from "@kokorone/shared";
|
||||||
|
import type { Archetype } from "../personality/archetype-params.js";
|
||||||
|
|
||||||
|
export type SentenceEndContour = "RISING" | "FALLING" | "FLAT";
|
||||||
|
|
||||||
|
export interface ProsodyTemplate {
|
||||||
|
rateDeltaPercent: number; // 語速變化百分比,正值變快
|
||||||
|
pitchDeltaSemitones: number; // 音高變化(半音),正值變高
|
||||||
|
volumeDeltaPercent: number; // 音量變化百分比,正值變大
|
||||||
|
contour: SentenceEndContour;
|
||||||
|
characteristic: string; // 描述性文字,供除錯/前端顯示
|
||||||
|
}
|
||||||
|
|
||||||
|
// P-3 情緒 → 韻律對照:情緒狀態機的輸出直接驅動韻律參數。
|
||||||
|
export const EMOTION_PROSODY_TABLE: Record<EmotionTag, ProsodyTemplate> = {
|
||||||
|
CALM: { rateDeltaPercent: 0, pitchDeltaSemitones: 0, volumeDeltaPercent: 0, contour: "FLAT", characteristic: "角色預設值" },
|
||||||
|
JOY: { rateDeltaPercent: 15, pitchDeltaSemitones: 1.5, volumeDeltaPercent: 10, contour: "RISING", characteristic: "句尾上揚、偶爾帶笑聲" },
|
||||||
|
SAD: { rateDeltaPercent: -20, pitchDeltaSemitones: -1.5, volumeDeltaPercent: -15, contour: "FALLING", characteristic: "句間停頓變長、句尾下沉" },
|
||||||
|
ALERT: { rateDeltaPercent: 5, pitchDeltaSemitones: 0, volumeDeltaPercent: 0, contour: "FLAT", characteristic: "語調變平、字句變短" },
|
||||||
|
SHY: { rateDeltaPercent: -5, pitchDeltaSemitones: 2, volumeDeltaPercent: -10, contour: "FLAT", characteristic: "結巴、語尾變小聲,忽快忽慢" },
|
||||||
|
GRUMPY: { rateDeltaPercent: 10, pitchDeltaSemitones: 1, volumeDeltaPercent: -5, contour: "FALLING", characteristic: "「哼」開頭,先大後小" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// P-1 Voice Sheet 預設值:依性格原型推導基準語速與音域幅度(同 G-5「原型 → 種子預設」的作法)。
|
||||||
|
export const DEFAULT_VOICE_SHEET_BY_ARCHETYPE: Record<Archetype, { baseTimbre: string; baseRate: number; pitchRangeSemitones: number }> = {
|
||||||
|
傲嬌: { baseTimbre: "清亮少女音", baseRate: 1.05, pitchRangeSemitones: 5 },
|
||||||
|
冷淡: { baseTimbre: "低沉平穩音", baseRate: 0.85, pitchRangeSemitones: 1.5 },
|
||||||
|
天然呆: { baseTimbre: "軟糯蘿莉音", baseRate: 0.95, pitchRangeSemitones: 4 },
|
||||||
|
元氣: { baseTimbre: "清亮少女音", baseRate: 1.2, pitchRangeSemitones: 6 },
|
||||||
|
大小姐: { baseTimbre: "低沉御姐音", baseRate: 0.95, pitchRangeSemitones: 3.5 },
|
||||||
|
三無: { baseTimbre: "軟糯蘿莉音", baseRate: 0.8, pitchRangeSemitones: 0.5 },
|
||||||
|
};
|
||||||
|
const DEFAULT_VOICE_SHEET_FALLBACK = { baseTimbre: "清亮少女音", baseRate: 1.0, pitchRangeSemitones: 3 };
|
||||||
|
export function defaultVoiceSheetFor(archetype: string) {
|
||||||
|
return DEFAULT_VOICE_SHEET_BY_ARCHETYPE[archetype as Archetype] ?? DEFAULT_VOICE_SHEET_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// P-2 非語言發聲庫:依情緒狀態的預設候選詞,角色的 Voice Sheet 口頭聲響庫可補充、禁則可過濾排除。
|
||||||
|
export const DEFAULT_INTERJECTIONS_BY_EMOTION: Record<EmotionTag, string[]> = {
|
||||||
|
CALM: ["嗯"],
|
||||||
|
JOY: ["欸嘿嘿", "呵呵"],
|
||||||
|
SAD: ["唔……", "……"],
|
||||||
|
ALERT: ["嗯?", "欸?!"],
|
||||||
|
SHY: ["那個……就是……", "唔……"],
|
||||||
|
GRUMPY: ["哼!", "哼……"],
|
||||||
|
};
|
||||||
|
|
||||||
|
// P-2 停頓:情緒強度達門檻(沿用 C-4 高情緒事件的判斷門檻)且屬於重大話題時,回答前故意停頓 1~2 秒。
|
||||||
|
export const SIGNIFICANT_PAUSE_THRESHOLD = 0.6;
|
||||||
|
export const SIGNIFICANT_PAUSE_SECONDS = [1, 2];
|
||||||
|
export const DEFAULT_PAUSE_SECONDS = 0.2;
|
||||||
|
|
||||||
|
// P-2 打斷與搶話:需要「talkative」特徵(見 archetype-params.ts)且親密度達朋友分層以上才可能插話。
|
||||||
|
export const INTERRUPT_MIN_INTIMACY = 40;
|
||||||
|
|
||||||
|
// P-2 親密度影響音量與氣音比例:對陌生人聲音端正清晰,對摯友放鬆、氣音比例上升。
|
||||||
|
export function intimacyVoiceModifiers(intimacy: number): { volumeMultiplier: number; breathinessRatio: number } {
|
||||||
|
const clamped = Math.max(0, Math.min(100, intimacy));
|
||||||
|
return {
|
||||||
|
volumeMultiplier: 1 - (clamped / 100) * 0.15, // 端正清晰(1.0) → 放鬆慵懶(0.85)
|
||||||
|
breathinessRatio: clamped / 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { TTSProvider, TTSSynthesisInput, SynthesizedAudio } from "./tts-provider.js";
|
||||||
|
import { hashToSeed } from "../llm/seeded-random.js";
|
||||||
|
|
||||||
|
const CHARS_PER_SECOND_AT_NORMAL_RATE = 6; // Mock 階段的粗略估算,不代表真實語速
|
||||||
|
|
||||||
|
// P-4 MockTTSProvider:輸出韻律標記與佔位音檔參照,不呼叫任何真實語音服務——
|
||||||
|
// 讓語音管線的其他機制(韻律解析、非語言發聲、副語言分析)都能在沒有真實 TTS 供應商的情況下完整測試。
|
||||||
|
@Injectable()
|
||||||
|
export class MockTTSProvider implements TTSProvider {
|
||||||
|
async synthesize(input: TTSSynthesisInput): Promise<SynthesizedAudio> {
|
||||||
|
const seed = input.seed ?? hashToSeed(input.text);
|
||||||
|
const { prosody, nonverbalCue, voiceSheet } = input;
|
||||||
|
|
||||||
|
const spokenText = nonverbalCue.interjection ? `${nonverbalCue.interjection} ${input.text}` : input.text;
|
||||||
|
const durationEstimateMs =
|
||||||
|
nonverbalCue.pauseSeconds * 1000 + (spokenText.length / CHARS_PER_SECOND_AT_NORMAL_RATE / prosody.rateMultiplier) * 1000;
|
||||||
|
|
||||||
|
const prosodyMarkup =
|
||||||
|
`<voice timbre="${voiceSheet.baseTimbre}">` +
|
||||||
|
`<break time="${nonverbalCue.pauseSeconds.toFixed(1)}s"/>` +
|
||||||
|
`<prosody rate="${prosody.rateMultiplier.toFixed(2)}" pitch="${prosody.pitchShiftSemitones >= 0 ? "+" : ""}${prosody.pitchShiftSemitones.toFixed(1)}st" volume="${prosody.volumeMultiplier.toFixed(2)}" contour="${prosody.contour}">` +
|
||||||
|
spokenText +
|
||||||
|
`</prosody></voice>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioRef: `mock://tts/${seed}`,
|
||||||
|
prosodyMarkup,
|
||||||
|
durationEstimateMs: Math.round(durationEstimateMs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { EmotionTag } from "@kokorone/shared";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service.js";
|
||||||
|
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
||||||
|
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||||
|
import { getArchetypeParams } from "../personality/archetype-params.js";
|
||||||
|
import { VoiceSheetService } from "./voice-sheet.service.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_INTERJECTIONS_BY_EMOTION,
|
||||||
|
SIGNIFICANT_PAUSE_THRESHOLD,
|
||||||
|
SIGNIFICANT_PAUSE_SECONDS,
|
||||||
|
DEFAULT_PAUSE_SECONDS,
|
||||||
|
INTERRUPT_MIN_INTIMACY,
|
||||||
|
} from "./constants.js";
|
||||||
|
|
||||||
|
export interface NonverbalCue {
|
||||||
|
interjection: string | null;
|
||||||
|
pauseSeconds: number;
|
||||||
|
canInterrupt: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldFor(tag: EmotionTag): "calm" | "joy" | "sad" | "alert" | "shy" | "grumpy" {
|
||||||
|
return tag.toLowerCase() as "calm" | "joy" | "sad" | "alert" | "shy" | "grumpy";
|
||||||
|
}
|
||||||
|
|
||||||
|
// P-2 對話節奏與非語言發聲:依情緒插入「嗯?」「唔……」等聲響與停頓,親密度與性格決定能不能插話。
|
||||||
|
@Injectable()
|
||||||
|
export class NonverbalService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly emotion: EmotionService,
|
||||||
|
private readonly relationship: RelationshipService,
|
||||||
|
private readonly voiceSheet: VoiceSheetService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async resolveCue(characterId: string, userId: string, now: Date = new Date()): Promise<NonverbalCue> {
|
||||||
|
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||||
|
const sheet = await this.voiceSheet.getOrSeedDefault(characterId);
|
||||||
|
const emotionState = await this.emotion.getState(characterId, now);
|
||||||
|
const emotionTag = dominantState(emotionState);
|
||||||
|
// 平靜本身不是「重大情緒事件」——calm 的數值代表「沒有明顯情緒」,不能當成訊號強度,
|
||||||
|
// 否則平靜時 calm=100 會被誤判成最強訊號,反而觸發本該只留給強烈情緒的長停頓。
|
||||||
|
const intensity = emotionTag === "CALM" ? 0 : (emotionState[fieldFor(emotionTag)] as number) / 100;
|
||||||
|
|
||||||
|
const candidates = [...DEFAULT_INTERJECTIONS_BY_EMOTION[emotionTag], ...sheet.verbalTics];
|
||||||
|
const allowed = candidates.filter((phrase) => !sheet.forbiddenSounds.includes(phrase));
|
||||||
|
const interjection = allowed.length > 0 ? allowed[0] : null;
|
||||||
|
|
||||||
|
const pauseSeconds =
|
||||||
|
intensity >= SIGNIFICANT_PAUSE_THRESHOLD
|
||||||
|
? SIGNIFICANT_PAUSE_SECONDS[0] + (SIGNIFICANT_PAUSE_SECONDS[1] - SIGNIFICANT_PAUSE_SECONDS[0]) * intensity
|
||||||
|
: DEFAULT_PAUSE_SECONDS;
|
||||||
|
|
||||||
|
const { relationship } = await this.relationship.getState(characterId, userId, now);
|
||||||
|
const archetypeParams = getArchetypeParams(character.personalityArchetype);
|
||||||
|
const canInterrupt = archetypeParams.traits.includes("talkative") && relationship.intimacy >= INTERRUPT_MIN_INTIMACY;
|
||||||
|
|
||||||
|
return { interjection, pauseSeconds, canInterrupt };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { EmotionTag } from "@kokorone/shared";
|
||||||
|
|
||||||
|
export interface ParalinguisticFeatures {
|
||||||
|
rate: number; // 相對語速,1.0 為正常,<1 變慢、>1 變快
|
||||||
|
volume: number; // 0~1,音量大小
|
||||||
|
tremor: number; // 0~1,聲音顫抖程度
|
||||||
|
pauseCount: number; // 這段話裡的停頓次數
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(value: number): number {
|
||||||
|
return Math.max(0, Math.min(1, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// P-5 副語言分析:STT 轉出文字之外,語速/音量/顫抖/停頓等特徵也送入情緒標記器——
|
||||||
|
// 「文字說沒事但聲音在抖」應被偵測為負向情緒,即使文字本身沒有任何負向關鍵字。
|
||||||
|
@Injectable()
|
||||||
|
export class ParalinguisticService {
|
||||||
|
deriveSignal(features: ParalinguisticFeatures): { tag: EmotionTag; intensity: number } | null {
|
||||||
|
const slowAndQuiet = features.rate <= 0.75 && features.volume <= 0.45;
|
||||||
|
if (features.tremor >= 0.4 || slowAndQuiet) {
|
||||||
|
const intensity = clamp01(features.tremor * 0.6 + (1 - features.rate) * 0.25 + (1 - features.volume) * 0.25);
|
||||||
|
return { tag: "SAD", intensity: Math.max(intensity, 0.3) };
|
||||||
|
}
|
||||||
|
if (features.rate >= 1.3 && features.volume >= 0.7 && features.tremor < 0.2) {
|
||||||
|
const intensity = clamp01((features.rate - 1) * 0.6 + features.volume * 0.3);
|
||||||
|
return { tag: "JOY", intensity: Math.max(intensity, 0.3) };
|
||||||
|
}
|
||||||
|
return null; // 沒有明顯的副語言訊號,交給文字本身的情緒標記結果決定。
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
||||||
|
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||||
|
import { VoiceSheetService } from "./voice-sheet.service.js";
|
||||||
|
import { EMOTION_PROSODY_TABLE, intimacyVoiceModifiers, type SentenceEndContour } from "./constants.js";
|
||||||
|
import type { EmotionTag } from "@kokorone/shared";
|
||||||
|
|
||||||
|
const REFERENCE_PITCH_RANGE = 4; // 對應 constants.ts 的通用預設音域幅度,作為縮放基準
|
||||||
|
|
||||||
|
export interface ResolvedProsody {
|
||||||
|
emotionTag: EmotionTag;
|
||||||
|
rateMultiplier: number; // 相對 1.0 的語速倍率(已套入角色基準語速)
|
||||||
|
pitchShiftSemitones: number; // 已依角色音域幅度縮放的音高變化
|
||||||
|
volumeMultiplier: number; // 已套入親密度調整的音量倍率
|
||||||
|
breathinessRatio: number; // 氣音比例(親密度越高越高)
|
||||||
|
contour: SentenceEndContour;
|
||||||
|
characteristic: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// P-3 語音標記層:情緒狀態機輸出直接驅動韻律參數,與韻律驅動同源(跟 O 群組表情解析同一批輸入)。
|
||||||
|
@Injectable()
|
||||||
|
export class ProsodyService {
|
||||||
|
constructor(
|
||||||
|
private readonly emotion: EmotionService,
|
||||||
|
private readonly relationship: RelationshipService,
|
||||||
|
private readonly voiceSheet: VoiceSheetService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async resolveProsody(characterId: string, userId: string, now: Date = new Date()): Promise<ResolvedProsody> {
|
||||||
|
const emotionState = await this.emotion.getState(characterId, now);
|
||||||
|
const emotionTag = dominantState(emotionState);
|
||||||
|
const template = EMOTION_PROSODY_TABLE[emotionTag];
|
||||||
|
|
||||||
|
const sheet = await this.voiceSheet.getOrSeedDefault(characterId);
|
||||||
|
const { relationship } = await this.relationship.getState(characterId, userId, now);
|
||||||
|
const { volumeMultiplier: intimacyVolume, breathinessRatio } = intimacyVoiceModifiers(relationship.intimacy);
|
||||||
|
|
||||||
|
const rateMultiplier = sheet.baseRate * (1 + template.rateDeltaPercent / 100);
|
||||||
|
// 音域幅度縮放:跟 O-4 表情外顯度縮放同一個設計原則——角色的音域幅度越窄(三無),
|
||||||
|
// 同一個情緒帶來的音高變化就越不明顯(近乎單音)。
|
||||||
|
const pitchShiftSemitones = template.pitchDeltaSemitones * (sheet.pitchRangeSemitones / REFERENCE_PITCH_RANGE);
|
||||||
|
const volumeMultiplier = intimacyVolume * (1 + template.volumeDeltaPercent / 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
emotionTag,
|
||||||
|
rateMultiplier,
|
||||||
|
pitchShiftSemitones,
|
||||||
|
volumeMultiplier,
|
||||||
|
breathinessRatio,
|
||||||
|
contour: template.contour,
|
||||||
|
characteristic: template.characteristic,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { TTSProvider, TTSSynthesisInput, SynthesizedAudio } from "./tts-provider.js";
|
||||||
|
|
||||||
|
// P-4 真實 TTS 供應商:待人工確認要接哪一家服務(技術選型文件尚未拍板),先留下同介面的空殼,
|
||||||
|
// 呼叫到才報錯,不在啟動時就讓整個服務炸掉——跟 F 群組 ClaudeProvider 的處理方式一致。
|
||||||
|
@Injectable()
|
||||||
|
export class RealTTSProvider implements TTSProvider {
|
||||||
|
async synthesize(_input: TTSSynthesisInput): Promise<SynthesizedAudio> {
|
||||||
|
throw new Error("RealTTSProvider 尚未實作:真實語音供應商待人工確認後再接(見技術選型「TTS 服務」)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { ResolvedProsody } from "./prosody.service.js";
|
||||||
|
import type { NonverbalCue } from "./nonverbal.service.js";
|
||||||
|
import type { VoiceSheetView } from "./voice-sheet.service.js";
|
||||||
|
|
||||||
|
export interface TTSSynthesisInput {
|
||||||
|
text: string;
|
||||||
|
voiceSheet: VoiceSheetView;
|
||||||
|
prosody: ResolvedProsody;
|
||||||
|
nonverbalCue: NonverbalCue;
|
||||||
|
seed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SynthesizedAudio {
|
||||||
|
audioRef: string; // 音檔參照(Mock 階段是佔位字串,不是真的音檔)
|
||||||
|
prosodyMarkup: string; // 類 SSML 的除錯用韻律標記字串
|
||||||
|
durationEstimateMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// P-4 TTSProvider 抽象:比照 F-1 LLMProvider 的介面設計,切換 Provider 不動呼叫端任何一行。
|
||||||
|
export interface TTSProvider {
|
||||||
|
synthesize(input: TTSSynthesisInput): Promise<SynthesizedAudio>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TTS_PROVIDER = Symbol("TTS_PROVIDER");
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user