feat: 完成 B 群組 — 資料層與核心領域模型

- 導入 Prisma 7.9.1 + SQLite(better-sqlite3 driver adapter),prisma.config.ts 管理連線設定
- 完整資料模型:User/Work/Character/CharacterAlias/EpisodicMemory/SemanticMemory/
  ProceduralRule/EmotionState/Relationship/SentimentLedgerEntry
- packages/db:Prisma Client 存取層(ESM,因 Prisma 7 產出的 generated client 僅支援 ESM)
- apps/api 隨之改為 ESM 以相容 packages/db
- packages/shared:新增角色/記憶/情緒/關係共用型別,api 與 web 皆從此匯入
- prisma/seed.ts:建立測試使用者與元氣型測試角色,含完整關係帳本與記憶種子資料
- apps/api:新增 GET /characters
- scripts/smoke/B.mjs:驗證資料表齊全、API 讀出種子角色、關係與記憶可寫入讀出
- 保留 Prisma 官方隨 CLI 附的 agent skill 文件(.agents/skills 等),供後續群組查閱

npm run restart && npm run smoke -- B 皆通過(B-V),A 群組冒煙測試無回歸。
This commit is contained in:
Jeffery
2026-08-13 09:43:15 +08:00
parent ae9c44dde7
commit f44200f543
124 changed files with 13566 additions and 48 deletions
@@ -0,0 +1,133 @@
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "works" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"volumeProgress" TEXT,
"worldview" TEXT,
"progressAnchor" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "characters" (
"id" TEXT NOT NULL PRIMARY KEY,
"workId" TEXT,
"source" TEXT NOT NULL,
"buildStatus" TEXT NOT NULL DEFAULT 'CANDIDATE',
"basicInfo" TEXT NOT NULL,
"backgroundStory" TEXT NOT NULL,
"personalityArchetype" TEXT NOT NULL,
"likesDislikes" TEXT NOT NULL,
"goalsObsessions" TEXT NOT NULL,
"speechStyle" TEXT NOT NULL,
"initialRelationValue" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "characters_workId_fkey" FOREIGN KEY ("workId") REFERENCES "works" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "character_aliases" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"formalName" TEXT NOT NULL,
"nickname" TEXT,
"calledByOthers" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "character_aliases_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "episodic_memories" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"occurredAt" DATETIME NOT NULL,
"emotionTag" TEXT NOT NULL,
"emotionIntensity" REAL NOT NULL,
"retrievalCount" INTEGER NOT NULL DEFAULT 0,
"lastRetrievedAt" DATETIME,
"source" TEXT NOT NULL,
"weight" REAL NOT NULL DEFAULT 1,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "episodic_memories_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "semantic_memories" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"fact" TEXT NOT NULL,
"about" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "semantic_memories_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "procedural_rules" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"situation" TEXT NOT NULL,
"responsePattern" TEXT NOT NULL,
"weight" REAL NOT NULL DEFAULT 1,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "procedural_rules_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "emotion_states" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"calm" REAL NOT NULL DEFAULT 100,
"joy" REAL NOT NULL DEFAULT 0,
"sad" REAL NOT NULL DEFAULT 0,
"alert" REAL NOT NULL DEFAULT 0,
"shy" REAL NOT NULL DEFAULT 0,
"grumpy" REAL NOT NULL DEFAULT 0,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "emotion_states_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "relationships" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"intimacy" INTEGER NOT NULL DEFAULT 0,
"trust" INTEGER NOT NULL DEFAULT 0,
"firstMet" DATETIME,
"lastInteraction" DATETIME,
"interactionCount" INTEGER NOT NULL DEFAULT 0,
"stage" TEXT NOT NULL DEFAULT 'STRANGER',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "relationships_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "relationships_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "sentiment_ledger_entries" (
"id" TEXT NOT NULL PRIMARY KEY,
"relationshipId" TEXT NOT NULL,
"date" DATETIME NOT NULL,
"event" TEXT NOT NULL,
"weight" REAL NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sentiment_ledger_entries_relationshipId_fkey" FOREIGN KEY ("relationshipId") REFERENCES "relationships" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "emotion_states_characterId_key" ON "emotion_states"("characterId");
-- CreateIndex
CREATE UNIQUE INDEX "relationships_characterId_userId_key" ON "relationships"("characterId", "userId");
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+4
View File
@@ -0,0 +1,4 @@
{
"private": true,
"type": "module"
}
+205
View File
@@ -0,0 +1,205 @@
// Kokorone 資料模型
// R-2 會將 provider 由 sqlite 切換為 postgresql,並為 MemoryRetriever 加上 pgvector 語意檢索。
generator client {
provider = "prisma-client"
output = "../packages/db/generated"
}
datasource db {
provider = "sqlite"
}
enum CharacterSource {
ORIGINAL // 原創
EXISTING_WORK // 既有作品
}
enum CharacterBuildStatus {
CANDIDATE // 候補
BUILT // 已建置
}
enum MemorySource {
INTERACTION // 互動
OFFLINE_GENERATED // 離線生成
SOURCE_EXTRACTION // 原作萃取
}
// 情緒標籤類型:對應情緒子系統六種狀態(見 D 群組)
enum EmotionTag {
CALM // 平靜
JOY // 愉悅
SAD // 低落
ALERT // 警戒
SHY // 害羞
GRUMPY // 彆扭
}
enum RelationshipStage {
STRANGER // 陌生 0-19
ACQUAINTANCE // 認識 20-39
FRIEND // 朋友 40-59
CLOSE_AMBIGUOUS // 摯友-曖昧 60-79
BONDED // 羈絆 80-100
}
model User {
id String @id @default(cuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
relationships Relationship[]
@@map("users")
}
model Work {
id String @id @default(cuid())
title String
volumeProgress String? // 卷數進度
worldview String? // 世界觀
progressAnchor String? // 作品進度錨定點(M-9/N 群組使用;描述作品實際描寫過的時期)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
characters Character[]
@@map("works")
}
model Character {
id String @id @default(cuid())
workId String?
work Work? @relation(fields: [workId], references: [id])
source CharacterSource
buildStatus CharacterBuildStatus @default(CANDIDATE)
// 角色設定表七欄位
basicInfo String // 基本資料:名字、年齡、外型、出身
backgroundStory String // 背景故事:成長經歷、重大事件
personalityArchetype String // 性格原型:傲嬌/冷淡/天然呆/元氣/大小姐/三無
likesDislikes String // 喜好/厭惡
goalsObsessions String // 目標/執念
speechStyle String // 說話方式:口癖、語尾詞、第一/第二人稱
initialRelationValue String? // 人際初始值:對誰親近、對誰有心結
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
aliases CharacterAlias[]
episodicMemories EpisodicMemory[]
semanticMemories SemanticMemory[]
proceduralRules ProceduralRule[]
emotionState EmotionState?
relationships Relationship[]
@@map("characters")
}
model CharacterAlias {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id])
formalName String // 正式名
nickname String? // 暱稱
calledByOthers String? // 他人稱呼(作品中其他角色如何稱呼)
createdAt DateTime @default(now())
@@map("character_aliases")
}
model EpisodicMemory {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id])
content String
occurredAt DateTime // 發生時間
emotionTag EmotionTag
emotionIntensity Float // 情緒強度
retrievalCount Int @default(0) // 提取次數
lastRetrievedAt DateTime?
source MemorySource
weight Float @default(1) // 權重(供遺忘/檢索排序使用)
createdAt DateTime @default(now())
@@map("episodic_memories")
}
model SemanticMemory {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id])
fact String // 去情境化事實
about String // 對象(關於誰/什麼)
createdAt DateTime @default(now())
@@map("semantic_memories")
}
model ProceduralRule {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id])
situation String // 情境
responsePattern String // 回應模式
weight Float @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("procedural_rules")
}
model EmotionState {
id String @id @default(cuid())
characterId String @unique
character Character @relation(fields: [characterId], references: [id])
calm Float @default(100) // 平靜
joy Float @default(0) // 愉悅
sad Float @default(0) // 低落
alert Float @default(0) // 警戒
shy Float @default(0) // 害羞
grumpy Float @default(0) // 彆扭
updatedAt DateTime @updatedAt
@@map("emotion_states")
}
model Relationship {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
intimacy Int @default(0) // 親密度 0~100
trust Int @default(0) // 信任度 0~100
firstMet DateTime?
lastInteraction DateTime?
interactionCount Int @default(0)
stage RelationshipStage @default(STRANGER)
ledgerEntries SentimentLedgerEntry[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([characterId, userId])
@@map("relationships")
}
model SentimentLedgerEntry {
id String @id @default(cuid())
relationshipId String
relationship Relationship @relation(fields: [relationshipId], references: [id])
date DateTime
event String
weight Float // 允許正負:正向事件為正值,負向事件為負值
createdAt DateTime @default(now())
@@map("sentiment_ledger_entries")
}
+131
View File
@@ -0,0 +1,131 @@
import "dotenv/config";
import { prisma } from "@kokorone/db";
async function main() {
const userData = {
name: "測試使用者",
};
const user = await prisma.user.upsert({
where: { id: "seed-user-primary" },
update: userData,
create: { id: "seed-user-primary", ...userData },
});
const characterData = {
source: "ORIGINAL" as const,
buildStatus: "BUILT" as const,
basicInfo: "小陽,17 歲,高中二年級生,短髮、笑容燦爛,出身普通家庭",
backgroundStory: "從小熱愛運動,國中時因為一場社團比賽的逆轉勝而確立了「全力以赴就會有轉機」的信念",
personalityArchetype: "元氣",
likesDislikes: "喜歡:運動、甜食、和朋友出去玩;討厭:早起、被潑冷水",
goalsObsessions: "想在畢業前和社團一起拿到全國大賽名次",
speechStyle: "第一人稱用「我」,語尾常帶「!」,說話直接、語速偏快",
initialRelationValue: "對使用者:好奇又友善,容易熱絡起來",
};
const character = await prisma.character.upsert({
where: { id: "seed-character-genki" },
update: characterData,
create: { id: "seed-character-genki", ...characterData },
});
const aliasData = {
characterId: character.id,
formalName: "小陽",
};
await prisma.characterAlias.upsert({
where: { id: "seed-alias-genki" },
update: aliasData,
create: { id: "seed-alias-genki", ...aliasData },
});
const relationshipData = {
characterId: character.id,
userId: user.id,
intimacy: 10,
trust: 10,
firstMet: new Date("2026-08-01T09:00:00+08:00"),
lastInteraction: new Date("2026-08-12T21:00:00+08:00"),
interactionCount: 3,
stage: "ACQUAINTANCE" as const,
};
const relationship = await prisma.relationship.upsert({
where: { characterId_userId: { characterId: character.id, userId: user.id } },
update: relationshipData,
create: { id: "seed-relationship-genki-primary", ...relationshipData },
});
const ledgerData = {
relationshipId: relationship.id,
date: new Date("2026-08-05T18:30:00+08:00"),
event: "使用者陪她練習到很晚,隔天比賽拿到好成績",
weight: 8,
};
await prisma.sentimentLedgerEntry.upsert({
where: { id: "seed-ledger-first-help" },
update: ledgerData,
create: { id: "seed-ledger-first-help", ...ledgerData },
});
const emotionData = {
calm: 70,
joy: 20,
sad: 0,
alert: 0,
shy: 0,
grumpy: 0,
};
await prisma.emotionState.upsert({
where: { characterId: character.id },
update: emotionData,
create: { characterId: character.id, ...emotionData },
});
const episodicMemoryData = {
characterId: character.id,
content: "使用者在社團比賽前一晚陪她加練到很晚,隔天比賽逆轉獲勝",
occurredAt: new Date("2026-08-05T21:00:00+08:00"),
emotionTag: "JOY" as const,
emotionIntensity: 0.9,
source: "INTERACTION" as const,
weight: 5,
};
await prisma.episodicMemory.upsert({
where: { id: "seed-memory-episodic-cheer" },
update: episodicMemoryData,
create: { id: "seed-memory-episodic-cheer", ...episodicMemoryData },
});
const semanticMemoryData = {
characterId: character.id,
fact: "使用者會在她練習時主動陪伴、給予鼓勵",
about: "使用者",
};
await prisma.semanticMemory.upsert({
where: { id: "seed-memory-semantic-sport" },
update: semanticMemoryData,
create: { id: "seed-memory-semantic-sport", ...semanticMemoryData },
});
const proceduralRuleData = {
characterId: character.id,
situation: "使用者傳來早安問候",
responsePattern: "以元氣滿滿的語氣回應,並附上今天的行程或心情",
weight: 1,
};
await prisma.proceduralRule.upsert({
where: { id: "seed-rule-greeting" },
update: proceduralRuleData,
create: { id: "seed-rule-greeting", ...proceduralRuleData },
});
console.log("種子資料建立完成:", { userId: user.id, characterId: character.id });
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});