feat: M3 小說解析 skill、合併詢問與個性推導 #10

Merged
jiantw83 merged 1 commits from issue-5 into main 2026-10-12 05:45:45 +00:00
10 changed files with 514 additions and 0 deletions
Showing only changes of commit 0b658ef878 - Show all commits
+9
View File
@@ -0,0 +1,9 @@
# 第 1 章 放學後的圖書室(原創範例)
春天的星見學院,櫻花正落。放學後的圖書室只剩夕陽。
「才、才不是為了你呢!」小葵把書塞進我懷裡,別開了臉。她的短髮在逆光下閃著栗色,血型是 A 型,這是她自己說的。
(真是個不坦率的傢伙。)我在心裡想。
「先輩,明天也要來喔!」學妹小綠笑著揮手。「先輩真的很可靠喔!」
+12
View File
@@ -0,0 +1,12 @@
# 範例章節完整流程(人工跑一次)
範例章節:`docs/fixtures/sample-chapter.md`(原創內容)。於 2026-10-12 以記憶體檔案系統跑完以下步驟,結果如下。
1. 抽取:地點「星見學院圖書室」、環境「春天、傍晚、櫻花」;人物小葵(main,tsundere,A 型、栗色短髮、身高 unknown)、小綠(minor)。
2. `planMerge`:首次無疑似同一人,主要角色小葵排在路人小綠之前。
3. 決定:兩者皆「新建」,`applyParseResult` 寫入 `world/places.json`、`world/environment.json`、`characters/<id>/core.json`、`canon/episodic.json`、`canon/lines.json`,無衝突;身高 `unknown` 不寫入。
4. 重跑同一章(小葵選合併):回報 `alreadyApplied: ["小葵"]`,檔案內容不變(冪等)。
5. 口癖:語尾「喔」、稱呼「先輩」(重複出現 ≥2 次才採用)。
6. 個性:tsundere 先驗 A=35;模型推論 A=90 附對白證據,被限制為 55(先驗 +20)。
結論:流程可走通;口頭禪歸納目前只能抓到語尾與稱呼,「才、才不是…」這類口頭禪仍須由 skill 中的模型判斷後補入。
+10
View File
@@ -0,0 +1,10 @@
# 研究待查證項目記錄(M3)
查證日期:2026-10-12。僅記錄實際查到的內容,查不到的標為未查證。
| 項目 | 狀態 | 說明 |
|---|---|---|
| 個性轉 PAD 的係數 | 未查證 | 出處為 Mehrabian 1996〈Analysis of the Big-five Personality Factors in Terms of the PAD Temperament Model〉(Australian Journal of Psychology 48, 86–92),為標準化變數的線性迴歸。搜尋結果未取得係數,實作 PAD 基線(M1/M2)前須讀原文 PDF(https://www.cs.uky.edu/~sgware/reading/papers/mehrabian1996analysis.pdf)。M3 不使用此係數。 |
| 檢索權重的原始出處 | 部分查證 | Generative Agents(Park 2023, arXiv 2304.03442)以時效、重要度、相關性三項各自 min-max 正規化後加總;時效為指數衰減(衰減係數 0.995/遊戲小時);重要度由 LLM 給 1–10。次級來源另列 (0.5, 3, 2) 的手調權重,屬各實作自行調整。本專案 (1, 1, 2, 0.5) 為自訂設計值,並非出自原論文;原論文權重是否為等權重尚未對照官方程式碼確認。 |
| MemGPT、Reflexion 細節 | 未查證 | 本里程碑不依賴,留待 M4(記憶鞏固與反思)前查證。 |
| LLM 對白推個性可信度偏低 | 沿用規格 | 規格引用的相關最高約 0.27 未重新查證,故個性只當先驗並保留人工覆寫。 |
@@ -0,0 +1,149 @@
import { readJson, writeJson } from './store'
import { INFO_FIELDS, LAYERS, SOURCE_RANK } from './types'
import type { ApplyReport, Candidate, Conflict, Decision, Fs, Info, InfoField, InfoValue, Line, Memory, ParseResult, ParsedCharacter, PlanItem } from './types'
export interface CharacterCore {
id: string
name: string
aliases: string[]
traits: string[]
info: Info
chapters: string[]
}
const MAX_LINES = 30
const MAX_LINE_LEN = 80
const charDir = (root: string, id: string) => `${root}/characters/${id}`
const slug = (name: string) => name.trim().toLowerCase().replace(/[\s/\\]+/g, '-')
export function listCharacters(fs: Fs, root: string): CharacterCore[] {
const ids = readJson<string[]>(fs, `${root}/characters/index.json`, [])
return ids.flatMap(id => {
const core = readJson<CharacterCore | null>(fs, `${charDir(root, id)}/core.json`, null)
return core ? [core] : []
})
}
function reasonsFor(c: ParsedCharacter, existing: CharacterCore): string[] {
const reasons: string[] = []
const names = [c.name, ...(c.aliases ?? [])]
const theirs = [existing.name, ...existing.aliases]
if (c.name === existing.name) reasons.push(`名字相同:${c.name}`)
for (const a of names) {
if (a !== existing.name && theirs.includes(a)) reasons.push(`別名吻合:${a}`)
}
if (!reasons.length && names.some(n => theirs.some(t => n.length >= 2 && t.length >= 2 && (n.includes(t) || t.includes(n))))) {
reasons.push('名字互相包含')
}
const shared = (c.traits ?? []).filter(t => existing.traits.includes(t))
if (shared.length >= 2) reasons.push(`特徵相同:${shared.join('、')}`)
return reasons
}
/** 產生逐一詢問的清單:主要角色在前、路人在後(可批次詢問),附疑似同一人的理由 */
export function planMerge(fs: Fs, root: string, result: ParseResult): PlanItem[] {
const existing = listCharacters(fs, root)
const items = result.characters.map(c => {
const candidates: Candidate[] = existing
.map(e => ({ id: e.id, reasons: reasonsFor(c, e) }))
.filter(x => x.reasons.length > 0)
return { name: c.name, role: c.role, candidates }
})
return [...items.filter(i => i.role === 'main'), ...items.filter(i => i.role !== 'main')]
}
const uniq = <T>(xs: T[]) => [...new Set(xs)]
function mergeInfo(character: string, core: CharacterCore, incoming: Info, resolutions: Record<string, 'keep' | 'replace'>, conflicts: Conflict[]) {
for (const field of INFO_FIELDS) {
const next = incoming[field]
if (!next || next.value === 'unknown') continue
const cur = core.info[field]
if (!cur || cur.value === 'unknown') {
core.info[field] = next
} else if (cur.value === next.value) {
if (rank(next) > rank(cur)) core.info[field] = next
} else {
const choice = resolutions[`${character}.${field}`]
if (choice === 'replace') core.info[field] = next
else if (choice !== 'keep') conflicts.push({ character, field, existing: cur, incoming: next })
}
}
}
const rank = (v: InfoValue) => (v.source ? SOURCE_RANK[v.source] : 0)
function appendById<T extends { id: string }>(list: T[], add: T[]): T[] {
const seen = new Set(list.map(x => x.id))
return [...list, ...add.filter(x => !seen.has(x.id))]
}
/**
* 套用解析結果。冪等以章節編號為鍵:同一章對同一角色只會寫入一次;
* 合併只追加、不覆蓋;基本資訊衝突回報給呼叫端逐欄位詢問(resolutions 帶回答案)。
*/
export function applyParseResult(
fs: Fs,
root: string,
result: ParseResult,
decisions: Record<string, Decision>,
resolutions: Record<string, 'keep' | 'replace'> = {},
): ApplyReport {
const report: ApplyReport = { applied: [], skipped: [], alreadyApplied: [], conflicts: [] }
const chapter = result.chapter
for (const kind of ['timeline', 'places', 'terms', 'environment'] as const) {
const entries = result.world?.[kind] ?? []
if (!entries.length) continue
const path = `${root}/world/${kind}.json`
const add = entries.map(e => ({ id: `${chapter}:${kind}:${e.order}`, chapter, order: e.order, text: e.text }))
writeJson(fs, path, appendById(readJson<typeof add>(fs, path, []), add))
}
for (const c of result.characters) {
const decision = decisions[c.name]
if (!decision) throw new Error(`缺少人物決定:${c.name}`)
if (decision.action === 'skip') { report.skipped.push(c.name); continue }
const index = readJson<string[]>(fs, `${root}/characters/index.json`, [])
let id: string
if (decision.action === 'merge') {
if (!index.includes(decision.target)) throw new Error(`合併目標不存在:${decision.target}`)
id = decision.target
} else {
id = slug(c.name)
for (let n = 2; index.includes(id); n++) id = `${slug(c.name)}-${n}`
}
const dir = charDir(root, id)
const core = readJson<CharacterCore>(fs, `${dir}/core.json`, { id, name: c.name, aliases: [], traits: [], info: {}, chapters: [] })
if (core.chapters.includes(chapter)) { report.alreadyApplied.push(c.name); continue }
const mine: Conflict[] = []
mergeInfo(c.name, core, c.info ?? {}, resolutions, mine)
report.conflicts.push(...mine)
core.aliases = uniq([...core.aliases, ...(c.name !== core.name ? [c.name] : []), ...(c.aliases ?? [])]).filter(a => a !== core.name)
core.traits = uniq([...core.traits, ...(c.traits ?? [])])
for (const layer of LAYERS) {
const add: Memory[] = (c.memories?.[layer] ?? []).map(m => ({ ...m, id: `${chapter}:${layer}:${m.order}`, chapter, layer }))
if (!add.length) continue
const path = `${dir}/canon/${layer}.json`
writeJson(fs, path, appendById(readJson<Memory[]>(fs, path, []), add))
}
if (c.lines?.length) {
const path = `${dir}/canon/lines.json`
const add = c.lines.map((l: Line) => ({ ...l, text: l.text.slice(0, MAX_LINE_LEN), id: `${chapter}:line:${l.order}`, chapter }))
writeJson(fs, path, appendById(readJson<(typeof add)[number][]>(fs, path, []), add).slice(-MAX_LINES))
}
// 有未解決衝突時不標記章節完成,讓回答後可重跑(記憶以 id 去重,不會重複)
if (!mine.length) core.chapters.push(chapter)
writeJson(fs, `${dir}/core.json`, core)
if (!index.includes(id)) writeJson(fs, `${root}/characters/index.json`, [...index, id])
report.applied.push(id)
}
return report
}
@@ -0,0 +1,147 @@
import { describe, test } from 'node:test'
import assert from 'node:assert/strict'
import { applyParseResult, listCharacters, planMerge } from './apply'
import { applyInference, initPersonality, overrideDim } from './personality'
import { summarizeTics } from './speech'
import { memoryFs } from './store'
import type { ParseResult } from './types'
const ROOT = '/c'
const ch1: ParseResult = {
chapter: '1',
world: { places: [{ text: '星見學院', order: 1 }], terms: [{ text: '星詠', order: 1 }] },
characters: [
{
name: '小葵', role: 'main', aliases: ['葵'], traits: ['tsundere', '短髮'],
info: { bloodType: { value: 'A', source: 'novel', confidence: 0.9 }, height: { value: '158cm', source: 'wiki', confidence: 0.5 } },
memories: { episodic: [{ text: '在校門口遇見主角', importance: 6, order: 1 }] },
lines: [{ text: '才、才不是為了你呢!', kind: 'speech', order: 1 }],
},
{ name: '路人甲', role: 'minor' },
],
}
const decide = { 小葵: { action: 'new' }, 路人甲: { action: 'skip' } } as const
describe('applyParseResult', () => {
test('重複套用同一章是冪等的', () => {
const fs = memoryFs()
applyParseResult(fs, ROOT, ch1, decide)
const snap = new Map(fs.files)
const r = applyParseResult(fs, ROOT, ch1, { ...decide, 小葵: { action: 'merge', target: '小葵' } })
assert.deepEqual(r.alreadyApplied, ['小葵'])
assert.deepEqual(fs.files, snap)
})
test('略過的人物不寫入', () => {
const fs = memoryFs()
const r = applyParseResult(fs, ROOT, ch1, decide)
assert.deepEqual(r.skipped, ['路人甲'])
assert.equal(listCharacters(fs, ROOT).length, 1)
})
test('合併新章節只追加記憶,並保留舊內容', () => {
const fs = memoryFs()
applyParseResult(fs, ROOT, ch1, decide)
applyParseResult(fs, ROOT, {
chapter: '2',
characters: [{ name: '葵', role: 'main', memories: { episodic: [{ text: '一起去圖書館', importance: 4, order: 1 }] } }],
}, { 葵: { action: 'merge', target: '小葵' } })
const mem = JSON.parse(fs.files.get('/c/characters/小葵/canon/episodic.json')!)
assert.deepEqual(mem.map((m: { id: string }) => m.id), ['1:episodic:1', '2:episodic:1'])
assert.equal(listCharacters(fs, ROOT)[0]!.chapters.length, 2)
})
test('基本資訊衝突不會被無聲覆寫,回答後才更新', () => {
const fs = memoryFs()
applyParseResult(fs, ROOT, ch1, decide)
const ch2: ParseResult = {
chapter: '2',
characters: [{ name: '小葵', role: 'main', info: { bloodType: { value: 'B', source: 'wiki', confidence: 0.4 }, hair: { value: '黑色短髮', source: 'novel', confidence: 0.9 } } }],
}
const d = { 小葵: { action: 'merge', target: '小葵' } } as const
const r = applyParseResult(fs, ROOT, ch2, d)
assert.equal(r.conflicts.length, 1)
assert.equal(r.conflicts[0]!.field, 'bloodType')
let core = listCharacters(fs, ROOT)[0]!
assert.equal(core.info.bloodType!.value, 'A')
assert.equal(core.info.hair!.value, '黑色短髮') // 無衝突的欄位照常補上
const r2 = applyParseResult(fs, ROOT, ch2, d, { '小葵.bloodType': 'replace' })
assert.equal(r2.conflicts.length, 0)
core = listCharacters(fs, ROOT)[0]!
assert.equal(core.info.bloodType!.value, 'B')
const mem = JSON.parse(fs.files.get('/c/characters/小葵/canon/episodic.json')!)
assert.equal(mem.length, 1) // 重跑沒有重複寫入
})
test('unknown 不會覆蓋既有值,高優先來源可升級同值', () => {
const fs = memoryFs()
applyParseResult(fs, ROOT, ch1, decide)
const r = applyParseResult(fs, ROOT, {
chapter: '3',
characters: [{ name: '小葵', role: 'main', info: { bloodType: { value: 'unknown', source: null, confidence: 0 }, height: { value: '158cm', source: 'novel', confidence: 0.9 } } }],
}, { 小葵: { action: 'merge', target: '小葵' } })
assert.equal(r.conflicts.length, 0)
const core = listCharacters(fs, ROOT)[0]!
assert.equal(core.info.bloodType!.value, 'A')
assert.equal(core.info.height!.source, 'novel')
})
test('世界觀庫以章節編號去重', () => {
const fs = memoryFs()
applyParseResult(fs, ROOT, ch1, decide)
applyParseResult(fs, ROOT, { ...ch1, characters: [] }, {})
assert.equal(JSON.parse(fs.files.get('/c/world/places.json')!).length, 1)
})
test('缺少人物決定會拒絕', () => {
assert.throws(() => applyParseResult(memoryFs(), ROOT, ch1, {}), /缺少人物決定/)
})
})
describe('planMerge', () => {
test('主要角色在前,並附疑似同一人的理由', () => {
const fs = memoryFs()
applyParseResult(fs, ROOT, ch1, decide)
const plan = planMerge(fs, ROOT, {
chapter: '2',
characters: [{ name: '路人乙', role: 'minor' }, { name: '葵', role: 'main', traits: ['tsundere', '短髮'] }],
})
assert.equal(plan[0]!.name, '葵')
assert.equal(plan[0]!.candidates[0]!.id, '小葵')
assert.ok(plan[0]!.candidates[0]!.reasons.some(r => r.includes('別名')))
assert.deepEqual(plan[1]!.candidates, [])
})
})
describe('personality', () => {
test('推論限制在先驗 ±20 並要求證據', () => {
const p = initPersonality(['tsundere'])
const out = applyInference(p, {
A: { value: 100, evidence: [{ chapter: '1', quote: '才不是為了你' }] },
N: { value: 0, evidence: [] },
})
assert.equal(out.dims.A.value, 55)
assert.equal(out.dims.N.value, p.dims.N.value)
assert.equal(out.dims.A.evidence.length, 1)
})
test('人工覆寫後鎖定,自動推導不再更新', () => {
let p = overrideDim(initPersonality(['genki']), 'E', 70)
p = applyInference(p, { E: { value: 95, evidence: [{ chapter: '1', quote: 'x' }] } })
assert.equal(p.dims.E.value, 70)
})
})
describe('summarizeTics', () => {
test('只採用重複出現的語尾與稱呼', () => {
const t = summarizeTics([
{ text: '先輩、今天也辛苦了にゃ', kind: 'speech', order: 1 },
{ text: '先輩はすごいにゃ', kind: 'speech', order: 2 },
{ text: '偶爾說一次', kind: 'monologue', order: 3 },
])
assert.ok(t.endings.includes('にゃ'))
assert.ok(t.address.includes('先輩'))
})
})
@@ -0,0 +1,63 @@
export const BIG5 = ['O', 'C', 'E', 'A', 'N'] as const
export type Dim = (typeof BIG5)[number]
export type Vec = Record<Dim, number>
export const MAX_DEVIATION = 20
/** 動漫屬性的 Big Five 先驗(設計啟發式,非學術標準) */
export const ARCHETYPE_PRIORS: Record<string, Vec> = {
tsundere: { O: 50, C: 55, E: 55, A: 35, N: 65 },
genki: { O: 70, C: 40, E: 90, A: 70, N: 35 },
kuudere: { O: 45, C: 65, E: 20, A: 45, N: 30 },
dandere: { O: 50, C: 55, E: 15, A: 65, N: 55 },
yandere: { O: 40, C: 50, E: 45, A: 25, N: 85 },
onee: { O: 55, C: 65, E: 60, A: 80, N: 25 },
}
export interface Evidence { chapter: string; quote: string }
export interface DimState { value: number; prior: number; evidence: Evidence[]; locked: boolean }
export interface Personality {
archetypes: string[]
dims: Record<Dim, DimState>
expressiveness: number
gap: number
}
const clamp = (x: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, x))
export function priorFor(archetypes: string[]): Vec {
const known = archetypes.filter(a => ARCHETYPE_PRIORS[a])
const out = {} as Vec
for (const d of BIG5) {
out[d] = known.length ? known.reduce((s, a) => s + ARCHETYPE_PRIORS[a]![d], 0) / known.length : 50
}
return out
}
export function initPersonality(archetypes: string[], expressiveness = 50, gap = 0): Personality {
const prior = priorFor(archetypes)
const dims = {} as Personality['dims']
for (const d of BIG5) dims[d] = { value: prior[d], prior: prior[d], evidence: [], locked: false }
return { archetypes, dims, expressiveness, gap }
}
/** 套用模型推論:限制在先驗 ±20、附證據;人工鎖定的維度不更新;缺證據不採用 */
export function applyInference(p: Personality, inferred: Partial<Record<Dim, { value: number; evidence: Evidence[] }>>): Personality {
const dims = { ...p.dims }
for (const d of BIG5) {
const inf = inferred[d]
const cur = dims[d]
if (!inf || cur.locked || !inf.evidence.length) continue
dims[d] = {
...cur,
value: clamp(inf.value, cur.prior - MAX_DEVIATION, cur.prior + MAX_DEVIATION),
evidence: [...cur.evidence, ...inf.evidence],
}
}
return { ...p, dims }
}
/** 人工覆寫並鎖定,之後自動推導不會改動 */
export function overrideDim(p: Personality, dim: Dim, value: number): Personality {
return { ...p, dims: { ...p.dims, [dim]: { ...p.dims[dim], value: clamp(value, 0, 100), locked: true } } }
}
@@ -0,0 +1,20 @@
import type { Line } from './types'
export interface SpeechTics { endings: string[]; catchphrases: string[]; address: string[] }
const ENDING = /([ぁ-んァ-ヶー]{1,2}|[啦喔啊呢吧嘛囉喲哦呀])[!?!?。…~~]*$/
const CALL = /([^\s,。!?、「」]{0,6}(?:さん|くん|ちゃん|様|先輩|同學|大人|先生|小姐))/g
/** 從對白與獨白歸納口癖:語尾、重複出現的口頭禪、稱呼;出現至少 minCount 次才採用 */
export function summarizeTics(lines: Line[], minCount = 2): SpeechTics {
const count = (items: string[]) => {
const m = new Map<string, number>()
for (const i of items) m.set(i, (m.get(i) ?? 0) + 1)
return [...m].filter(([, n]) => n >= minCount).sort((a, b) => b[1] - a[1]).map(([k]) => k)
}
const texts = lines.map(l => l.text.trim()).filter(Boolean)
const endings = count(texts.flatMap(t => { const m = ENDING.exec(t); return m ? [m[1]!] : [] }))
const address = count(texts.flatMap(t => [...t.matchAll(CALL)].map(m => m[1]!)))
const catchphrases = count(texts.flatMap(t => t.split(/[!?!?。…、,,\s]+/).filter(s => s.length >= 2 && s.length <= 8)))
return { endings, catchphrases, address }
}
@@ -0,0 +1,19 @@
import type { Fs } from './types'
export function readJson<T>(fs: Fs, path: string, fallback: T): T {
const raw = fs.read(path)
return raw === null ? fallback : (JSON.parse(raw) as T)
}
export function writeJson(fs: Fs, path: string, data: unknown): void {
fs.write(path, JSON.stringify(data, null, 2))
}
export function memoryFs(): Fs & { files: Map<string, string> } {
const files = new Map<string, string>()
return {
files,
read: p => files.get(p) ?? null,
write: (p, d) => void files.set(p, d),
}
}
@@ -0,0 +1,58 @@
export interface Fs {
read(path: string): string | null
/** 原子寫入:先寫暫存檔再改名 */
write(path: string, data: string): void
}
export type Source = 'novel' | 'official' | 'wiki'
export const SOURCE_RANK: Record<Source, number> = { novel: 3, official: 2, wiki: 1 }
export const INFO_FIELDS = ['birthday', 'zodiac', 'bloodType', 'hair', 'face', 'height', 'weight', 'measurements'] as const
export type InfoField = (typeof INFO_FIELDS)[number]
export interface InfoValue {
value: string
source: Source | null
confidence: number
chapter?: string
}
export type Info = Partial<Record<InfoField, InfoValue>>
export type Layer = 'episodic' | 'semantic' | 'emotional' | 'procedural'
export const LAYERS: Layer[] = ['episodic', 'semantic', 'emotional', 'procedural']
export interface MemoryInput { text: string; importance: number; order: number }
export interface Memory extends MemoryInput { id: string; chapter: string; layer: Layer }
export interface Line { text: string; kind: 'speech' | 'monologue'; order: number }
export interface ParsedCharacter {
name: string
aliases?: string[]
role: 'main' | 'minor'
traits?: string[]
info?: Info
memories?: Partial<Record<Layer, MemoryInput[]>>
lines?: Line[]
}
export interface WorldEntry { text: string; order: number }
export interface ParseResult {
chapter: string
world?: { timeline?: WorldEntry[]; places?: WorldEntry[]; terms?: WorldEntry[]; environment?: WorldEntry[] }
characters: ParsedCharacter[]
}
export type Decision = { action: 'merge'; target: string } | { action: 'new' } | { action: 'skip' }
export interface Candidate { id: string; reasons: string[] }
export interface PlanItem { name: string; role: 'main' | 'minor'; candidates: Candidate[] }
export interface Conflict { character: string; field: InfoField; existing: InfoValue; incoming: InfoValue }
export interface ApplyReport {
applied: string[]
skipped: string[]
alreadyApplied: string[]
conflicts: Conflict[]
}
+27
View File
@@ -0,0 +1,27 @@
---
name: ingest
description: 解析一章輕小說(txt 或 md),抽出世界觀與人物,逐一詢問合併、新建或略過,並寫入 cockpit 角色資料。使用者提供小說章節檔或要求建立/更新角色設定時使用。
---
# 小說章節解析
輸入:單章 txt 或 md 與章節編號。不保存整章原文,只存結構化摘要與簡短對白範例(每句 ≤80 字)。
## 流程
1. **讀取與分段**:超過約 6000 字的章節依段落/場景切成多段,逐段抽取後合併為一份結果,出現順序(`order`)沿用全章連續編號。
2. **抽取**:時間、地點、環境、用語(寫入共用世界觀庫);人物、對白、獨白(寫入角色)。人物標 `main`(有戲份的主要角色)或 `minor`(路人)。
3. **整理成 `ParseResult`**(型別見 `hooks/companion/core/types.ts`):
- 記憶分四層:`episodic` 情節、`semantic` 語意、`emotional` 情感、`procedural` 程序;每筆含 `importance`(1–10)與 `order`。
- 基本資訊欄位:birthday、zodiac、bloodType、hair、face、height、weight、measurements。每欄附 `source`(`novel` > `official` > `wiki`)與 `confidence`(0–1);原文沒寫的先搜尋,查不到填 `value: "unknown"`,不可猜測。
4. **逐一詢問人物**:呼叫 `planMerge` 取得清單(主要角色在前,路人最後批次問)。每位問「合併 / 新建 / 略過」,並顯示疑似同一人的理由(名字、別名、特徵)。
5. **套用**:呼叫 `applyParseResult(fs, root, result, decisions, resolutions)`。
- 同一章重跑不會重複寫入(以章節編號冪等);合併只追加不覆蓋。
- 回傳的 `conflicts` 是基本資訊衝突,**逐欄位**問使用者保留或取代,答案放進 `resolutions["人物.欄位"]`(`keep` / `replace`)後再套用一次。更新既有值前一律先問。
6. **口癖與個性**:用 `summarizeTics` 由對白與獨白歸納語尾、口頭禪、稱呼;個性先用屬性標籤取先驗(`initPersonality`),再把模型推論交給 `applyInference`(自動限制在先驗 ±20,每個維度必須附章節與對白證據)。使用者手動調整的維度用 `overrideDim` 鎖定,之後不再自動更新。
## 注意
- 模型從對白推得的個性可信度偏低,只當先驗。
- 不要在 repo 內保存受版權保護的長篇設定或台詞;資料寫在使用者家目錄的 cockpit 資料夾。
- 範例完整流程見 `docs/ingest-walkthrough.md`。