diff --git a/docs/briefs/user-preference-rag-todo.md b/docs/briefs/user-preference-rag-todo.md index d6fea5b..9a55473 100644 --- a/docs/briefs/user-preference-rag-todo.md +++ b/docs/briefs/user-preference-rag-todo.md @@ -1,63 +1,97 @@ -# 用户偏好 RAG 库(待定) +# 用户偏好库 / 文风库 -> 状态:**未实现**。先记设计共识与未决项,尤其 RAG 插入点。 -## 已对齐 -### 写库(学习,不改游玩上下文) +> 状态:**节点绑定(无 RAG)**。偏好与文风分库;创作节点声明 `libraries` 后注入,库空跳过。 -- 累计对话轮次 ≥ **10**(可在设置中改)后,尝试从最近对话**总结**用户偏好。 -- 弹出**审核卡**:通过 / 编辑后通过 / 合并到已有 / 跳过。 -- 通过后才写入全局偏好库;支持**去重**与**更新已有条目**。 -- 「随机插一条」= 往库里记候选,**不**修改当前轮 prompt。 -### 读库(仅创建期) -- 根据用户输入 **Boss 直聘式**召回匹配条目(卡片点选)。 -- 选中结果收成**本局固定上下文**(`resident_context` / 叙事指南合并),不在游玩期动态插入。 +## 两库 -### 条目形状(草案) -- `id`, `title`, `kind`(style|taboo|tone|format|other) -- `keys[]`(检索用), `content`, `mount[]`, `always_on?` -- `source`(manual|learned), `status`(active|archived) -## 未决:RAG 怎么插 +| 库 | 存盘 | 绑定节点 | 注入 | -当前仓库**无 embedding / 向量检索**。需先定插入架构,再写代码。 +|----|------|----------|------| -| 选项 | 做法 | 优点 | 风险 | -|------|------|------|------| -| A. 关键词召回 | `keys` + 标题匹配用户输入 | 零依赖、快落地 | 召回弱 | -| B. 创建期 LLM 选条 | 把库摘要交给 design-flow 一步选 | 与现有编排一致 | token、不稳定 | -| C. 本地 embedding | 条目向量化 + 相似度 Top-K | 真 RAG | 新依赖、索引维护 | -| D. 混合 | always_on + 关键词粗筛 +(可选)embedding 精排 | 可渐进 | 实现稍复杂 | +| **文风库** `style-packs` | `~/.writing-agent/style-packs.json` | 叙事指南、叙事指南与故事推进 | 【文风库 · 可选用】 | -**读库插入落点(创建期)候选:** +| **偏好库** `preferences` | `~/.writing-agent/preferences.json` | 用户需求 | 【偏好库 · 可选用】 | -1. `设计.用户偏好.选用集` fragment → `refine` 合并进 `resident_context` / 叙事 `情境备用` / `绝对禁忌` -2. 仅 `resident_context[]`,不经过叙事指南 -3. 新设计步「用户偏好」专产选用集 -**写库触发落点候选:** -1. `session-manager` 回合末计数 + 调 summarize API -2. 独立 `PreferenceService` + `/api/preferences/*` -3. 审核 UI 挂在 `SessionView.preferenceReview` +## 读写 -## 建议实现顺序(定好 RAG 后再做) -1. 库 store + 手写样例条目 -2. 设置项 `preferenceReviewEveryTurns`(默认 10) -3. 读库:关键词召回 + Boss 卡片 + 选用集 tag -4. `refine` 合并选用集 → 固定上下文 -5. 写库:轮次触发 + 总结 + 审核 + 去重写回 -6. (可选)embedding RAG -## 相关现有基建 +| 阶段 | 行为 | + +|------|------| + +| **库页** | 顶栏「库」:手写 CRUD + LLM 提取入库 | + +| **创作节点** | design-step / 对话落盘按 `libraries` 注入目录;节点选用写入本局产物 | + +| **开玩** | **不再**全量自动摊偏好;靠产物 `设计.用户需求` / `设计.叙事指南*` 挂载 | + +| **游玩** | 每隔 N 回合采集偏好候选(设置项);收下写入全局偏好库 | + + + +## 偏好条目形状 + + + +- `id`, `content`, `status`(active|archived), `createdAt`, `updatedAt` + + + +## 文风包形状 + + + +- `id`, `name`, `content`, `samples?`, `status`, `createdAt`, `updatedAt` + + + +## 设置 + + + +- `preferenceCollectEveryTurns`:采集间隔(默认 10;0=关) + +- 设置页「用户偏好」:只留间隔;CRUD 在库页 + + + +## API + + + +- `GET/POST /api/preferences`;`PUT/DELETE /api/preferences/:id` + +- `POST /api/preferences/extract` + +- `GET/POST /api/style-packs`;`PUT/DELETE /api/style-packs/:id` + +- `POST /api/style-packs/extract` + +- `POST /api/sessions/:id/preferences/review` + + + +## 实现 + + + +- `src/libraries/registry.ts` — 库注册 + `formatBoundLibrariesForPrompt` + +- `src/style-pack/` — 文风库 + +- `src/preference/` — 偏好库 + extract + +- `skills/.../modules/user-requirements/` — 用户需求节点 + +- `web/libraries.html` — 库页 + -- 常驻:`src/skills/resident-context.ts`,`设计.worker集.resident_context` -- 投影排序:`context-order` inserts -- 叙事写法档:`设计.叙事指南` → `情境备用` -- 设置:`src/config/settings.ts` -- 无游玩期上下文注入需求(与 `context-assembly.md` 一致) diff --git a/src/libraries/index.ts b/src/libraries/index.ts new file mode 100644 index 0000000..12510e8 --- /dev/null +++ b/src/libraries/index.ts @@ -0,0 +1,9 @@ +export { + registerLibrary, + getLibrary, + listRegisteredLibraries, + parseLibraryIds, + formatBoundLibrariesForPrompt, + collectLibraryIdsFromModules, + type LibraryProvider, +} from "./registry.js"; diff --git a/src/libraries/registry.ts b/src/libraries/registry.ts new file mode 100644 index 0000000..e317038 --- /dev/null +++ b/src/libraries/registry.ts @@ -0,0 +1,95 @@ +/** + * 节点可绑定的全局库:某能力声明 libraries 后,design-step 注入对应块。 + * 新库:实现 formatForPrompt,在本文件 register。 + */ + +import { formatPreferenceCatalogForPrompt } from "../preference/catalog.js"; +import { formatStylePackCatalogForPrompt } from "../style-pack/catalog.js"; + +export type LibraryProvider = { + id: string; + /** 给人看的短名 */ + label: string; + /** 空串 = 库空或无可注入,跳过 */ + formatForPrompt: () => string; +}; + +const providers = new Map(); + +export function registerLibrary(provider: LibraryProvider): void { + const id = provider.id.trim(); + if (!id) return; + providers.set(id, { ...provider, id }); +} + +export function getLibrary(id: string): LibraryProvider | undefined { + return providers.get(id.trim()); +} + +export function listRegisteredLibraries(): LibraryProvider[] { + return [...providers.values()]; +} + +/** 从 catalog / meta 原始值收成 id 列表 */ +export function parseLibraryIds(raw: unknown): string[] | undefined { + if (!Array.isArray(raw) || raw.length === 0) return undefined; + const ids: string[] = []; + const seen = new Set(); + for (const item of raw) { + const id = typeof item === "string" ? item.trim() : ""; + if (!id || seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + return ids.length ? ids : undefined; +} + +/** + * 按节点绑定的库 id 拼注入块;未知 id 忽略;库空跳过。 + */ +export function formatBoundLibrariesForPrompt( + libraryIds: readonly string[] | undefined, +): string { + if (!libraryIds?.length) return ""; + const blocks: string[] = []; + for (const id of libraryIds) { + const provider = providers.get(id.trim()); + if (!provider) continue; + const text = provider.formatForPrompt().trim(); + if (text) blocks.push(text); + } + return blocks.join("\n\n"); +} + +// —— 内置库 —— +registerLibrary({ + id: "style-packs", + label: "文风库", + formatForPrompt: formatStylePackCatalogForPrompt, +}); + +registerLibrary({ + id: "preferences", + label: "偏好库", + formatForPrompt: formatPreferenceCatalogForPrompt, +}); + +/** + * 从模块列表收集绑定的库 id(去重、保序)。 + * 对话落盘:通常传入 intake≠recipe 的模块。 + */ +export function collectLibraryIdsFromModules( + modules: ReadonlyArray<{ libraries?: readonly string[] }>, +): string[] { + const ids: string[] = []; + const seen = new Set(); + for (const m of modules) { + for (const raw of m.libraries ?? []) { + const id = typeof raw === "string" ? raw.trim() : ""; + if (!id || seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + } + return ids; +} diff --git a/src/preference/catalog.ts b/src/preference/catalog.ts new file mode 100644 index 0000000..f9f8c10 --- /dev/null +++ b/src/preference/catalog.ts @@ -0,0 +1,57 @@ +/** + + * 偏好库 → 提示词块(由 libraries 注册表按节点绑定调用)。 + + */ + + + +import { + + ensureStarterPreferences, + + listActivePreferences, + + type PreferenceEntry, + +} from "./store.js"; + + + +export function formatPreferenceCatalogForPrompt( + + entries?: PreferenceEntry[], + +): string { + + ensureStarterPreferences(); + + const active = (entries ?? listActivePreferences()).filter((e) => + + e.content.trim(), + + ); + + if (!active.length) return ""; + + + + const lines = active.map((e, i) => `${i + 1}. ${e.content.trim()}`); + + + + return `## 【偏好库 · 可选用】 + + + +跨局硬约束条目(禁区 / 纠偏 / 默认取向等)。用户点名、对齐某条、或明确「本局要进库里某某」时:写入本步产物;可改写为可执行句子,勿整库无脑粘贴。 + +与文风库分工:本库不管遣词示范,只管「必须遵守 / 绝对不要」。无匹配则照常追问,勿强行套用。 + + + +${lines.join("\n")}`; + +} + + diff --git a/src/preference/collect.ts b/src/preference/collect.ts new file mode 100644 index 0000000..37a070c --- /dev/null +++ b/src/preference/collect.ts @@ -0,0 +1,128 @@ +/** + * 游玩期偏好采集:隔 N 个用户回合,从近窗对话抽出可复用跨局偏好候选。 + * 不改本局「设计.用户约束」;候选须经审核卡才写入全局库。 + */ + +import { randomUUID } from "node:crypto"; +import type { LlmProvider } from "../llm/client.js"; +import { completeStructured } from "../llm/structured-complete.js"; +import { listActivePreferences } from "./store.js"; + +export type PreferenceCandidate = { + id: string; + content: string; +}; + +const COLLECT_SCHEMA = { + type: "object", + additionalProperties: false, + required: ["candidates"], + properties: { + candidates: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["content"], + properties: { + content: { type: "string" }, + }, + }, + }, + }, +} as const; + +function buildCollectPrompt(params: { + recentDialogue: string; + existing: string[]; +}): string { + const existingBlock = params.existing.length + ? params.existing.map((c, i) => `${i + 1}. ${c}`).join("\n") + : "(库空)"; + return `你正在从游玩对话中采集用户的**跨局偏好**候选。 + +任务:读近窗对话,判断用户是否表达了可复用的喜好/禁区/节奏/写法纠偏。有则写入 candidates;没有则 candidates 为空数组。 + +规则: +1. 只收跨局可复用约束,不收本局剧情指令(如「这章让她出门」)。 +2. 每条写成可执行做法,带替代行为;禁止只写「不要 xxx」。 +3. 与已有条目近义则不要再提。 +4. 一次最多 2 条;拿不准就 0 条。 +5. 力度偏「默认避开」;只有用户说死了才写成绝对禁止。 + +已有偏好: +${existingBlock} + +近窗对话: +${params.recentDialogue}`; +} + +function parseCandidates(parsed: unknown): PreferenceCandidate[] { + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return []; + const raw = (parsed as { candidates?: unknown }).candidates; + if (!Array.isArray(raw)) return []; + const out: PreferenceCandidate[] = []; + for (const item of raw) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const content = String( + (item as { content?: unknown }).content ?? "", + ).trim(); + if (!content) continue; + out.push({ id: randomUUID(), content }); + if (out.length >= 2) break; + } + return out; +} + +/** + * 调用 LLM 产出候选;失败或空则返回 []。 + */ +export async function collectPreferenceCandidates(params: { + llm: LlmProvider; + recentDialogue: string; +}): Promise { + const dialogue = params.recentDialogue.trim(); + if (!dialogue) return []; + const existing = listActivePreferences().map((e) => e.content); + try { + const result = await completeStructured( + params.llm, + [ + { + role: "user", + content: buildCollectPrompt({ + recentDialogue: dialogue, + existing, + }), + }, + ], + { + schema: COLLECT_SCHEMA as unknown as Record, + name: "preference_candidates", + schemaIsLoose: true, + caller: "preference_collect", + }, + ); + return parseCandidates(result.parsed); + } catch { + return []; + } +} + +/** 从会话消息拼近窗(用户 + 助手可见文),供采集 */ +export function formatRecentDialogueForCollect( + messages: Array<{ role: string; text?: string; body?: string }>, + limit = 12, +): string { + const slice = messages.slice(-limit); + const lines: string[] = []; + for (const m of slice) { + const role = m.role === "user" ? "用户" : m.role === "assistant" ? "助手" : m.role; + const text = (m.body ?? m.text ?? "").trim(); + if (!text) continue; + // 跳过系统调度句 + if (/^\[(总管|阶段机|游玩|创作|对话落盘)/.test(text)) continue; + lines.push(`${role}:${text.slice(0, 800)}`); + } + return lines.join("\n\n"); +} diff --git a/src/preference/constraints.ts b/src/preference/constraints.ts new file mode 100644 index 0000000..048a2ef --- /dev/null +++ b/src/preference/constraints.ts @@ -0,0 +1,205 @@ +/** + * 创作期:把全局偏好库全量摊成「设计.用户约束」,挂进游玩固定上下文。 + * 开玩后本局只读;游玩采集只写回全局库,不改本 tag。 + */ + +import type { Blackboard } from "../blackboard/blackboard.js"; +import { + CONTEXT_ORDER_SCHEMA, + CONTEXT_ORDER_TAG, + mergeContextOrderIntoWorkerSetJson, + parseContextOrder, + renumberContextOrder, + type ContextOrderDoc, + type ContextOrderInsert, +} from "../skills/context-order.js"; +import { + WORKER_SET_DRAFT_TAG, + WORKER_SET_FINAL_TAG, +} from "../skills/creation-units.js"; +import { + DEFAULT_PLAY_SLOT_REFS, + parsePlaySlots, +} from "../skills/play-slots.js"; +import { parseWorkerSetYaml } from "../skills/worker-set-parse.js"; +import { + ensureStarterPreferences, + listActivePreferences, + type PreferenceEntry, +} from "./store.js"; + +export const USER_CONSTRAINTS_TAG = "设计.用户约束"; + +const IDENTITY = + "【用户约束】跨局偏好。约束展示与写法,不改舞台真值;与本局世界观许可冲突时以本段为准。"; + +function tryParseOrder(raw: string): ContextOrderDoc | undefined { + const t = raw.trim(); + if (!t) return undefined; + try { + return parseContextOrder(JSON.parse(t)); + } catch { + return parseContextOrder(t); + } +} + +/** 由偏好条目拼成本局产物正文;空库返回空串 */ +export function buildUserConstraintsContent( + entries?: PreferenceEntry[], +): string { + const source = entries ?? listActivePreferences(); + const active = source.filter((e) => e.status === "active" && e.content.trim()); + if (!active.length) return ""; + const lines = active.map((e, i) => `${i + 1}. ${e.content.trim()}`); + return `${IDENTITY}\n\n${lines.join("\n")}`; +} + +/** 写入黑板;库空则清空 tag 正文(避免陈旧约束残留) */ +export function writeUserConstraintsTag(board: Blackboard): { + content: string; + count: number; +} { + ensureStarterPreferences(); + const entries = listActivePreferences(); + const content = buildUserConstraintsContent(entries); + board.write({ + tag: USER_CONSTRAINTS_TAG, + content, + source: "system:user-constraints", + }); + return { content, count: entries.length }; +} + +function alreadyHasRef(inserts: ContextOrderInsert[], ref: string): boolean { + return inserts.some((ins) => ins.ref === ref); +} + +/** + * 在 persona 之后插入用户约束(靠上,高于世界观长文)。 + * 目标槽:主世界层 + 叙事转述(有则挂)。 + */ +export function ensureUserConstraintsInContextOrder( + doc: ContextOrderDoc, +): ContextOrderDoc { + const gmRef = DEFAULT_PLAY_SLOT_REFS.gm; + const narratorRef = DEFAULT_PLAY_SLOT_REFS.narrator; + const slots = doc.slots.map((slot) => { + const isGm = slot.ref === gmRef || /world-simulator|主世界/.test(slot.ref); + const isNarrator = slot.ref === narratorRef || /narrator|转述/.test(slot.ref); + if (!isGm && !isNarrator) return slot; + if (alreadyHasRef(slot.inserts, USER_CONSTRAINTS_TAG)) return slot; + + const personaIdx = slot.inserts.findIndex( + (ins) => ins.ref === "worker.persona", + ); + const insertAt = personaIdx >= 0 ? personaIdx + 1 : 0; + const nextInserts = [...slot.inserts]; + nextInserts.splice(insertAt, 0, { + order: 0, + ref: USER_CONSTRAINTS_TAG, + projection: "full", + note: "用户跨局约束(全量)", + }); + return { ...slot, inserts: nextInserts }; + }); + + return renumberContextOrder({ + ...doc, + schema: CONTEXT_ORDER_SCHEMA, + slots, + }); +} + +/** + * 全量落盘 + 挂入运行规格 / 投影排序。 + * 库空:清空 tag,并从 inserts 摘掉该 ref。 + */ +export function applyUserConstraintsToPlaySpec(board: Blackboard): { + content: string; + count: number; + mounted: boolean; +} { + const { content, count } = writeUserConstraintsTag(board); + + const finalRaw = + board.getContentByTag(WORKER_SET_FINAL_TAG)?.trim() || + board.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim() || + ""; + const orderRaw = board.getContentByTag(CONTEXT_ORDER_TAG)?.trim() || ""; + + let orderDoc = + tryParseOrder(orderRaw) ?? + (() => { + if (!finalRaw) return undefined; + try { + const row = JSON.parse(finalRaw) as Record; + if (row.context_order && typeof row.context_order === "object") { + return parseContextOrder(row.context_order); + } + } catch { + /* ignore */ + } + return undefined; + })(); + + if (!orderDoc && finalRaw) { + const parsed = parseWorkerSetYaml(finalRaw); + const playSlots = parsed?.play_slots ?? parsePlaySlots(undefined); + orderDoc = { + schema: CONTEXT_ORDER_SCHEMA, + brief: "用户约束挂载", + play_slots: playSlots, + slots: [ + { + ref: DEFAULT_PLAY_SLOT_REFS.gm, + label: "主世界层", + inserts: [ + { order: 0, ref: "worker.persona", projection: "fixed" }, + { + order: 1, + ref: USER_CONSTRAINTS_TAG, + projection: "full", + note: "用户跨局约束(全量)", + }, + { order: 2, ref: "用户.最新输入", projection: "full" }, + ], + }, + ], + }; + } + + if (!orderDoc) { + return { content, count, mounted: false }; + } + + if (!content.trim()) { + // 摘掉空约束 ref + orderDoc = renumberContextOrder({ + ...orderDoc, + slots: orderDoc.slots.map((slot) => ({ + ...slot, + inserts: slot.inserts.filter((ins) => ins.ref !== USER_CONSTRAINTS_TAG), + })), + }); + } else { + orderDoc = ensureUserConstraintsInContextOrder(orderDoc); + } + + board.write({ + tag: CONTEXT_ORDER_TAG, + content: JSON.stringify(orderDoc, null, 2), + source: "system:user-constraints", + }); + + const baseJson = + finalRaw.trim() || + JSON.stringify({ play_slots: orderDoc.play_slots ?? {}, brief: "用户约束挂载" }, null, 2); + const workerSetJson = mergeContextOrderIntoWorkerSetJson(baseJson, orderDoc); + board.write({ + tag: WORKER_SET_FINAL_TAG, + content: workerSetJson, + source: "system:user-constraints", + }); + + return { content, count, mounted: Boolean(content.trim()) }; +} diff --git a/src/preference/extract.ts b/src/preference/extract.ts new file mode 100644 index 0000000..703c468 --- /dev/null +++ b/src/preference/extract.ts @@ -0,0 +1,257 @@ +/** + + * 偏好提取:对话 → 可入库的短约束条目候选。 + + */ + + + +import type { LlmProvider } from "../llm/client.js"; + +import { completeStructured } from "../llm/structured-complete.js"; + + + +export type PreferenceExtractMessage = { + + role: "user" | "assistant"; + + content: string; + +}; + + + +export type PreferenceExtractCandidate = { + + content: string; + + ready: boolean; + +}; + + + +export type PreferenceExtractResult = { + + reply: string; + + candidates: PreferenceExtractCandidate[]; + +}; + + + +const EXTRACT_SCHEMA = { + + type: "object", + + additionalProperties: false, + + required: ["reply", "candidates"], + + properties: { + + reply: { type: "string" }, + + candidates: { + + type: "array", + + items: { + + type: "object", + + additionalProperties: false, + + required: ["content", "ready"], + + properties: { + + content: { type: "string" }, + + ready: { type: "boolean" }, + + }, + + }, + + }, + + }, + +} as const; + + + +function buildSystemPrompt(params: { + + draftCandidates: string; + +}): string { + + const draft = params.draftCandidates.trim() || "(尚无候选)"; + + return `你正在帮用户从对话中提取**跨卡可复用偏好条目**(硬约束)。 + + + +任务:只写短、可执行的禁区/纠偏/默认取向;不写文风遣词、不写世界观与剧情。产物供「用户需求」节点选用。 + + + +每条 content: + +- 一句到三句;写「不要 X,改做 Y」优于空泛「注意分寸」 + +- 一条只钉一件事;多件事拆成多条 + + + +规则: + +1. reply:用人话简短回应;缺料时问 1~2 个具体点。 + +2. candidates:当前最好的条目列表(可增量改);未成形可空数组。 + +3. ready=true 仅当该条已够入库;否则 false,继续追问。 + +4. 不要清空用户已认可的好条目。 + + + +当前候选草稿: + +${draft}`; + +} + + + +function parseResult(parsed: unknown): PreferenceExtractResult { + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + + return { + + reply: "未能解析提取结果,请再试一次。", + + candidates: [], + + }; + + } + + const row = parsed as Record; + + const reply = + + typeof row.reply === "string" && row.reply.trim() + + ? row.reply.trim() + + : "已记录。"; + + const candidates: PreferenceExtractCandidate[] = []; + + if (Array.isArray(row.candidates)) { + + for (const item of row.candidates) { + + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + + const c = item as Record; + + const content = typeof c.content === "string" ? c.content.trim() : ""; + + if (!content) continue; + + candidates.push({ + + content, + + ready: c.ready === true, + + }); + + } + + } + + return { reply, candidates }; + +} + + + +export async function extractPreferenceTurn(params: { + + llm: LlmProvider; + + messages: PreferenceExtractMessage[]; + + draftCandidates?: PreferenceExtractCandidate[]; + +}): Promise { + + const history = params.messages + + .filter((m) => m.content.trim()) + + .slice(-16) + + .map((m) => ({ + + role: m.role, + + content: m.content.trim(), + + })); + + if (!history.length) { + + throw new Error("请先说点对偏好/禁区的要求"); + + } + + + + const draftText = (params.draftCandidates ?? []) + + .filter((c) => c.content.trim()) + + .map((c, i) => `${i + 1}. ${c.content.trim()}${c.ready ? " 〔可入库〕" : ""}`) + + .join("\n"); + + + + const system = buildSystemPrompt({ draftCandidates: draftText }); + + + + const result = await completeStructured( + + params.llm, + + [{ role: "system", content: system }, ...history], + + { + + schema: EXTRACT_SCHEMA as unknown as Record, + + name: "preference_extract", + + schemaIsLoose: true, + + caller: "preference_extract", + + }, + + ); + + return parseResult(result.parsed); + +} + + diff --git a/src/preference/index.ts b/src/preference/index.ts new file mode 100644 index 0000000..e6ac625 --- /dev/null +++ b/src/preference/index.ts @@ -0,0 +1,38 @@ +export { + listPreferences, + listActivePreferences, + getPreference, + createPreference, + updatePreference, + deletePreference, + acceptPreferenceCandidate, + loadPreferenceStore, + ensureStarterPreferences, + STARTER_PREFERENCES, + type PreferenceEntry, + type PreferenceStatus, + type PreferenceStoreFile, +} from "./store.js"; + +export { + USER_CONSTRAINTS_TAG, + buildUserConstraintsContent, + writeUserConstraintsTag, + applyUserConstraintsToPlaySpec, + ensureUserConstraintsInContextOrder, +} from "./constraints.js"; + +export { + collectPreferenceCandidates, + formatRecentDialogueForCollect, + type PreferenceCandidate, +} from "./collect.js"; + +export { formatPreferenceCatalogForPrompt } from "./catalog.js"; + +export { + extractPreferenceTurn, + type PreferenceExtractMessage, + type PreferenceExtractCandidate, + type PreferenceExtractResult, +} from "./extract.js"; diff --git a/src/preference/store.ts b/src/preference/store.ts new file mode 100644 index 0000000..c213868 --- /dev/null +++ b/src/preference/store.ts @@ -0,0 +1,222 @@ +/** + * 全局用户偏好库:跨局约束条目(禁区 / 节奏 / 写法纠偏等)。 + * 手写与游玩采集入库后同形;采集入库须经审核卡。 + */ + +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js"; + +export type PreferenceStatus = "active" | "archived"; + +export type PreferenceEntry = { + id: string; + /** 可执行约束正文(含替代做法) */ + content: string; + status: PreferenceStatus; + createdAt: string; + updatedAt: string; +}; + +export type PreferenceStoreFile = { + version: 1; + entries: PreferenceEntry[]; +}; + +const FILE_NAME = "preferences.json"; + +/** + * 内置起步条目(稳定 id)。库空或缺少对应 id 时写入;用户可改/归档/删。 + * 写法:短、具体、可执行;不写「何时静音 / 本局例外」——交给选用/筛选层。 + */ +export const STARTER_PREFERENCES: ReadonlyArray<{ + id: string; + content: string; +}> = [ + { + id: "starter-no-user-ntr-victim", + content: + "用户代入角色不做被 NTR 的一方(恋人/伴侣与第三者发生性或背叛性感情,用户角色受害或被欺瞒)。用户角色 NTR 别人不受此限。", + }, + { + id: "starter-default-male-hetero-yuri-ok", + content: + "禁止对男同性恋内容的直接描写,减少或忽略间接描写。默认用户角色为男性,情爱与性吸引朝向女性;女同性恋内容可写。", + }, +]; + +function storePath(): string { + return path.join(getUserDataDir(), FILE_NAME); +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function buildStarterEntries(at = nowIso()): PreferenceEntry[] { + return STARTER_PREFERENCES.map((s) => ({ + id: s.id, + content: s.content, + status: "active" as const, + createdAt: at, + updatedAt: at, + })); +} + +function defaultStore(): PreferenceStoreFile { + return { version: 1, entries: buildStarterEntries() }; +} + +/** 缺哪些起步 id 就补哪些;不覆盖用户已改过的同 id 正文 */ +export function ensureStarterPreferences(): PreferenceEntry[] { + const store = loadPreferenceStore(); + const have = new Set(store.entries.map((e) => e.id)); + const at = nowIso(); + let added = 0; + for (const s of STARTER_PREFERENCES) { + if (have.has(s.id)) continue; + store.entries.push({ + id: s.id, + content: s.content, + status: "active", + createdAt: at, + updatedAt: at, + }); + added += 1; + } + if (added) savePreferenceStore(store); + return store.entries; +} + +function normalizeStatus(raw: unknown): PreferenceStatus { + return raw === "archived" ? "archived" : "active"; +} + +function normalizeStore(raw: unknown): PreferenceStoreFile { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return defaultStore(); + } + const row = raw as Record; + if (row.version !== 1 || !Array.isArray(row.entries)) { + return defaultStore(); + } + const entries: PreferenceEntry[] = []; + for (const item of row.entries) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const p = item as Record; + const id = typeof p.id === "string" ? p.id.trim() : ""; + const content = typeof p.content === "string" ? p.content.trim() : ""; + if (!id || !content) continue; + entries.push({ + id, + content, + status: normalizeStatus(p.status), + createdAt: typeof p.createdAt === "string" ? p.createdAt : nowIso(), + updatedAt: typeof p.updatedAt === "string" ? p.updatedAt : nowIso(), + }); + } + return { version: 1, entries }; +} + +export function loadPreferenceStore(): PreferenceStoreFile { + ensureUserDataDirs(); + const file = storePath(); + if (!existsSync(file)) { + const created = defaultStore(); + writeFileSync(file, JSON.stringify(created, null, 2), "utf8"); + return created; + } + try { + const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown; + const normalized = normalizeStore(parsed); + // 旧空库:补起步条目 + if (normalized.entries.length === 0) { + const seeded = defaultStore(); + writeFileSync(file, JSON.stringify(seeded, null, 2), "utf8"); + return seeded; + } + return normalized; + } catch { + return defaultStore(); + } +} + +export function savePreferenceStore(store: PreferenceStoreFile): void { + ensureUserDataDirs(); + writeFileSync(storePath(), JSON.stringify(store, null, 2), "utf8"); +} + +export function listPreferences(opts?: { + status?: PreferenceStatus | "all"; +}): PreferenceEntry[] { + const status = opts?.status ?? "all"; + const entries = loadPreferenceStore().entries; + if (status === "all") return entries; + return entries.filter((e) => e.status === status); +} + +export function listActivePreferences(): PreferenceEntry[] { + return listPreferences({ status: "active" }); +} + +export function getPreference(id: string): PreferenceEntry | null { + return loadPreferenceStore().entries.find((e) => e.id === id) ?? null; +} + +export function createPreference(input: { + content: string; + status?: PreferenceStatus; +}): PreferenceEntry { + const content = input.content.trim(); + if (!content) throw new Error("偏好内容不能为空"); + const store = loadPreferenceStore(); + const t = nowIso(); + const entry: PreferenceEntry = { + id: randomUUID(), + content, + status: input.status === "archived" ? "archived" : "active", + createdAt: t, + updatedAt: t, + }; + store.entries.push(entry); + savePreferenceStore(store); + return entry; +} + +export function updatePreference( + id: string, + patch: { content?: string; status?: PreferenceStatus }, +): PreferenceEntry { + const store = loadPreferenceStore(); + const idx = store.entries.findIndex((e) => e.id === id); + if (idx < 0) throw new Error("偏好条目不存在"); + const cur = store.entries[idx]!; + const content = + patch.content !== undefined ? patch.content.trim() : cur.content; + if (!content) throw new Error("偏好内容不能为空"); + const next: PreferenceEntry = { + ...cur, + content, + status: patch.status !== undefined ? normalizeStatus(patch.status) : cur.status, + updatedAt: nowIso(), + }; + store.entries[idx] = next; + savePreferenceStore(store); + return next; +} + +export function deletePreference(id: string): void { + const store = loadPreferenceStore(); + const next = store.entries.filter((e) => e.id !== id); + if (next.length === store.entries.length) { + throw new Error("偏好条目不存在"); + } + store.entries = next; + savePreferenceStore(store); +} + +/** 审核通过后写入;content 以用户编辑后的正文为准 */ +export function acceptPreferenceCandidate(content: string): PreferenceEntry { + return createPreference({ content, status: "active" }); +} diff --git a/src/style-pack/catalog.ts b/src/style-pack/catalog.ts new file mode 100644 index 0000000..a6d1e71 --- /dev/null +++ b/src/style-pack/catalog.ts @@ -0,0 +1,31 @@ +/** + * 文风库 → 提示词块(由 libraries 注册表按节点绑定调用)。 + */ + +import { + listActiveStylePacks, + type StylePackEntry, +} from "./store.js"; + +export function formatStylePackCatalogForPrompt( + entries?: StylePackEntry[], +): string { + const packs = (entries ?? listActiveStylePacks()).filter((e) => + e.content.trim(), + ); + if (!packs.length) return ""; + + const blocks = packs.map((p, i) => { + const body = p.content.trim(); + const clipped = + body.length > 2400 ? `${body.slice(0, 2400)}\n…(已截断)` : body; + return `### ${i + 1}. ${p.name.trim()}\n\n${clipped}`; + }); + + return `## 【文风库 · 可选用】 + +跨卡「怎么写」包。用户点名、贴样本对上、或明确「用库里某某」时:优先据此落成本步产物里「怎么写」的部分,再按本局美学微调;不要重做文风问卷。 +库内只规定遣词/示范/笔墨/禁忌/写法档;不写世界观与推进。无匹配则照常追问,勿强行套用。 + +${blocks.join("\n\n")}`; +} diff --git a/src/style-pack/extract.ts b/src/style-pack/extract.ts new file mode 100644 index 0000000..54166e3 --- /dev/null +++ b/src/style-pack/extract.ts @@ -0,0 +1,124 @@ +/** + * 文风提取:对话 + 样本 → 可入库的「怎么写」草稿。 + */ + +import type { LlmProvider } from "../llm/client.js"; +import { completeStructured } from "../llm/structured-complete.js"; + +export type StyleExtractMessage = { + role: "user" | "assistant"; + content: string; +}; + +export type StyleExtractDraft = { + name: string; + content: string; + ready: boolean; +}; + +export type StyleExtractResult = { + reply: string; + draft: StyleExtractDraft; +}; + +const EXTRACT_SCHEMA = { + type: "object", + additionalProperties: false, + required: ["reply", "name", "content", "ready"], + properties: { + reply: { type: "string" }, + name: { type: "string" }, + content: { type: "string" }, + ready: { type: "boolean" }, + }, +} as const; + +function buildSystemPrompt(params: { + samples: string; + draftName: string; + draftContent: string; +}): string { + const samples = params.samples.trim() || "(未贴样本)"; + const draft = params.draftContent.trim() + ? `当前草稿名:${params.draftName.trim() || "(未命名)"}\n\n${params.draftContent.trim()}` + : "(尚无草稿)"; + return `你正在帮用户从对话与样本中提取一份**跨卡可复用文风包**。 + +任务:只规定「怎么写」,不写世界观、剧情、角色设定、推进方式。产物供叙事指南等节点直接采用。 + +content 应覆盖(有则写、无则略): +- 叙事纲领(体裁/调性一句话) +- 风格与遣词(原则 + 至少 1 段示范) +- 笔墨焦点(详写/略写) +- 禁忌与不偏好(绝对禁忌 ≠ 不偏好) +- 写法档(官能/爽文/恐怖/情色等;无切换则写「无切换」) +- 示例对话(若样本或用户给出) + +规则: +1. reply:用人话简短回应;缺料时问 1~2 个具体点,可给选项。 +2. name:短名(如「锋利爽文」「冷静官能」);未成形可空串。 +3. content:可执行正文(Markdown 小节即可);禁止写本局剧情。 +4. ready=true 仅当遣词+示范+禁忌已够入库;否则 false,继续追问。 +5. 每轮都给出目前最好的 name/content(可增量改),不要清空已有好内容。 + +用户样本: +${samples} + +当前草稿: +${draft}`; +} + +function parseResult(parsed: unknown): StyleExtractResult { + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { + reply: "未能解析提取结果,请再试一次。", + draft: { name: "", content: "", ready: false }, + }; + } + const row = parsed as Record; + const reply = + typeof row.reply === "string" && row.reply.trim() + ? row.reply.trim() + : "已记录。"; + const name = typeof row.name === "string" ? row.name.trim() : ""; + const content = typeof row.content === "string" ? row.content.trim() : ""; + const ready = row.ready === true && Boolean(name && content); + return { reply, draft: { name, content, ready } }; +} + +export async function extractStylePackTurn(params: { + llm: LlmProvider; + messages: StyleExtractMessage[]; + samples?: string; + draftName?: string; + draftContent?: string; +}): Promise { + const history = params.messages + .filter((m) => m.content.trim()) + .slice(-16) + .map((m) => ({ + role: m.role, + content: m.content.trim(), + })); + if (!history.length) { + throw new Error("请先说点对文风的要求,或贴一段样本"); + } + + const system = buildSystemPrompt({ + samples: params.samples ?? "", + draftName: params.draftName ?? "", + draftContent: params.draftContent ?? "", + }); + + const result = await completeStructured( + params.llm, + [{ role: "system", content: system }, ...history], + { + schema: EXTRACT_SCHEMA as unknown as Record, + name: "style_pack_extract", + schemaIsLoose: true, + caller: "style_pack_extract", + }, + ); + return parseResult(result.parsed); +} diff --git a/src/style-pack/index.ts b/src/style-pack/index.ts new file mode 100644 index 0000000..b463972 --- /dev/null +++ b/src/style-pack/index.ts @@ -0,0 +1,21 @@ +export { + listStylePacks, + listActiveStylePacks, + getStylePack, + createStylePack, + updateStylePack, + deleteStylePack, + loadStylePackStore, + type StylePackEntry, + type StylePackStatus, + type StylePackStoreFile, +} from "./store.js"; + +export { + extractStylePackTurn, + type StyleExtractMessage, + type StyleExtractDraft, + type StyleExtractResult, +} from "./extract.js"; + +export { formatStylePackCatalogForPrompt } from "./catalog.js"; diff --git a/src/style-pack/store.ts b/src/style-pack/store.ts new file mode 100644 index 0000000..07a626c --- /dev/null +++ b/src/style-pack/store.ts @@ -0,0 +1,191 @@ +/** + * 全局文风库:跨卡复用的「怎么写」包(遣词 / 示范 / 禁忌 / 写法档等)。 + * 与偏好库分离:偏好=硬约束全量挂载;文风=创作节点选用。 + */ + +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js"; + +export type StylePackStatus = "active" | "archived"; + +export type StylePackEntry = { + id: string; + /** 短名,便于列表与选用 */ + name: string; + /** 只规定「怎么写」:遣词、示范、笔墨、禁忌、写法档、示例对话等 */ + content: string; + /** 抽取时留下的样本原文(可选) */ + samples?: string; + status: StylePackStatus; + createdAt: string; + updatedAt: string; +}; + +export type StylePackStoreFile = { + version: 1; + entries: StylePackEntry[]; +}; + +const FILE_NAME = "style-packs.json"; + +function storePath(): string { + return path.join(getUserDataDir(), FILE_NAME); +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function defaultStore(): StylePackStoreFile { + return { version: 1, entries: [] }; +} + +function normalizeStatus(raw: unknown): StylePackStatus { + return raw === "archived" ? "archived" : "active"; +} + +function normalizeStore(raw: unknown): StylePackStoreFile { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return defaultStore(); + } + const row = raw as Record; + if (row.version !== 1 || !Array.isArray(row.entries)) { + return defaultStore(); + } + const entries: StylePackEntry[] = []; + for (const item of row.entries) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const p = item as Record; + const id = typeof p.id === "string" ? p.id.trim() : ""; + const name = typeof p.name === "string" ? p.name.trim() : ""; + const content = typeof p.content === "string" ? p.content.trim() : ""; + if (!id || !name || !content) continue; + const samples = + typeof p.samples === "string" && p.samples.trim() + ? p.samples.trim() + : undefined; + entries.push({ + id, + name, + content, + ...(samples ? { samples } : {}), + status: normalizeStatus(p.status), + createdAt: typeof p.createdAt === "string" ? p.createdAt : nowIso(), + updatedAt: typeof p.updatedAt === "string" ? p.updatedAt : nowIso(), + }); + } + return { version: 1, entries }; +} + +export function loadStylePackStore(): StylePackStoreFile { + ensureUserDataDirs(); + const file = storePath(); + if (!existsSync(file)) { + const created = defaultStore(); + writeFileSync(file, JSON.stringify(created, null, 2), "utf8"); + return created; + } + try { + const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown; + return normalizeStore(parsed); + } catch { + return defaultStore(); + } +} + +export function saveStylePackStore(store: StylePackStoreFile): void { + ensureUserDataDirs(); + writeFileSync(storePath(), JSON.stringify(store, null, 2), "utf8"); +} + +export function listStylePacks(opts?: { + status?: StylePackStatus | "all"; +}): StylePackEntry[] { + const status = opts?.status ?? "all"; + const entries = loadStylePackStore().entries; + if (status === "all") return entries; + return entries.filter((e) => e.status === status); +} + +export function listActiveStylePacks(): StylePackEntry[] { + return listStylePacks({ status: "active" }); +} + +export function getStylePack(id: string): StylePackEntry | null { + return loadStylePackStore().entries.find((e) => e.id === id) ?? null; +} + +export function createStylePack(input: { + name: string; + content: string; + samples?: string; + status?: StylePackStatus; +}): StylePackEntry { + const name = input.name.trim(); + const content = input.content.trim(); + if (!name) throw new Error("文风名称不能为空"); + if (!content) throw new Error("文风内容不能为空"); + const store = loadStylePackStore(); + const t = nowIso(); + const samples = input.samples?.trim() || undefined; + const entry: StylePackEntry = { + id: randomUUID(), + name, + content, + ...(samples ? { samples } : {}), + status: input.status === "archived" ? "archived" : "active", + createdAt: t, + updatedAt: t, + }; + store.entries.push(entry); + saveStylePackStore(store); + return entry; +} + +export function updateStylePack( + id: string, + patch: { + name?: string; + content?: string; + samples?: string | null; + status?: StylePackStatus; + }, +): StylePackEntry { + const store = loadStylePackStore(); + const idx = store.entries.findIndex((e) => e.id === id); + if (idx < 0) throw new Error("文风包不存在"); + const cur = store.entries[idx]!; + const name = patch.name !== undefined ? patch.name.trim() : cur.name; + const content = + patch.content !== undefined ? patch.content.trim() : cur.content; + if (!name) throw new Error("文风名称不能为空"); + if (!content) throw new Error("文风内容不能为空"); + let samples = cur.samples; + if (patch.samples !== undefined) { + samples = patch.samples?.trim() || undefined; + } + const next: StylePackEntry = { + ...cur, + name, + content, + ...(samples ? { samples } : {}), + status: patch.status !== undefined ? normalizeStatus(patch.status) : cur.status, + updatedAt: nowIso(), + }; + if (!samples) delete (next as { samples?: string }).samples; + store.entries[idx] = next; + saveStylePackStore(store); + return next; +} + +export function deleteStylePack(id: string): void { + const store = loadStylePackStore(); + const next = store.entries.filter((e) => e.id !== id); + if (next.length === store.entries.length) { + throw new Error("文风包不存在"); + } + store.entries = next; + saveStylePackStore(store); +} diff --git a/tests/libraries.test.ts b/tests/libraries.test.ts new file mode 100644 index 0000000..ff90381 --- /dev/null +++ b/tests/libraries.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +describe("libraries registry", () => { + beforeEach(() => { + process.env.WRITING_AGENT_DATA_DIR = mkdtempSync( + path.join(tmpdir(), "wa-lib-"), + ); + }); + + it("formats style-packs and preferences when non-empty", async () => { + const { createStylePack } = await import("../src/style-pack/store.js"); + const { ensureStarterPreferences } = await import( + "../src/preference/store.js" + ); + const { + formatBoundLibrariesForPrompt, + collectLibraryIdsFromModules, + } = await import("../src/libraries/index.js"); + + ensureStarterPreferences(); + createStylePack({ + name: "锋利爽文", + content: "短句推进;示范:他抬眼,门开了。", + }); + + const style = formatBoundLibrariesForPrompt(["style-packs"]); + expect(style).toContain("【文风库 · 可选用】"); + expect(style).toContain("锋利爽文"); + + const pref = formatBoundLibrariesForPrompt(["preferences"]); + expect(pref).toContain("【偏好库 · 可选用】"); + expect(pref).toContain("被 NTR"); + + expect(formatBoundLibrariesForPrompt(["unknown-lib"])).toBe(""); + expect( + collectLibraryIdsFromModules([ + { libraries: ["style-packs"] }, + { libraries: ["preferences", "style-packs"] }, + ]), + ).toEqual(["style-packs", "preferences"]); + }); + + it("skips empty style-packs library", async () => { + const { formatBoundLibrariesForPrompt } = await import( + "../src/libraries/index.js" + ); + // 空文风库 → 空串;偏好库有起步条目仍会有内容 + expect(formatBoundLibrariesForPrompt(["style-packs"])).toBe(""); + }); +}); + diff --git a/tests/preference.test.ts b/tests/preference.test.ts new file mode 100644 index 0000000..76dfd69 --- /dev/null +++ b/tests/preference.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Blackboard } from "../src/blackboard/blackboard.js"; + +describe("preference store", () => { + beforeEach(() => { + process.env.WRITING_AGENT_DATA_DIR = mkdtempSync( + path.join(tmpdir(), "wa-pref-"), + ); + }); + + it("seeds starter preferences on empty store", async () => { + const { + STARTER_PREFERENCES, + ensureStarterPreferences, + listActivePreferences, + loadPreferenceStore, + } = await import("../src/preference/store.js"); + + const seeded = loadPreferenceStore(); + expect(seeded.entries.length).toBe(STARTER_PREFERENCES.length); + expect(listActivePreferences().map((e) => e.id).sort()).toEqual( + STARTER_PREFERENCES.map((s) => s.id).sort(), + ); + ensureStarterPreferences(); + expect(listActivePreferences()).toHaveLength(STARTER_PREFERENCES.length); + expect( + listActivePreferences().some((e) => e.content.includes("被 NTR")), + ).toBe(true); + expect( + listActivePreferences().some((e) => e.content.includes("男同性恋")), + ).toBe(true); + }); + + it("creates, lists, archives, and deletes custom entries", async () => { + const { + STARTER_PREFERENCES, + createPreference, + listActivePreferences, + listPreferences, + updatePreference, + deletePreference, + loadPreferenceStore, + } = await import("../src/preference/store.js"); + + loadPreferenceStore(); + const starterCount = STARTER_PREFERENCES.length; + const a = createPreference({ + content: "关系线保持专一;不写出轨已成。", + }); + expect(listActivePreferences()).toHaveLength(starterCount + 1); + updatePreference(a.id, { status: "archived" }); + expect(listActivePreferences()).toHaveLength(starterCount); + expect(listPreferences()).toHaveLength(starterCount + 1); + deletePreference(a.id); + expect(listPreferences()).toHaveLength(starterCount); + }); +}); + +describe("user constraints", () => { + beforeEach(() => { + process.env.WRITING_AGENT_DATA_DIR = mkdtempSync( + path.join(tmpdir(), "wa-pref-c-"), + ); + }); + + it("builds full dump and mounts into play spec", async () => { + const { + STARTER_PREFERENCES, + createPreference, + loadPreferenceStore, + } = await import("../src/preference/store.js"); + const { + USER_CONSTRAINTS_TAG, + buildUserConstraintsContent, + applyUserConstraintsToPlaySpec, + } = await import("../src/preference/constraints.js"); + + loadPreferenceStore(); + createPreference({ content: "不要反复报精确数字;用量感与约数。" }); + createPreference({ content: "推进偏慢;每轮只推进一个小决定点。" }); + + const body = buildUserConstraintsContent(); + expect(body).toContain("【用户约束】"); + expect(body).toContain("精确数字"); + expect(body).toContain("推进偏慢"); + expect(body).toContain("被 NTR"); + + const board = new Blackboard(); + board.write({ + tag: "设计.worker集", + content: JSON.stringify({ + play_slots: { gm: true, narrator: true, auditor: false, perspective: false }, + brief: "test", + }), + source: "test", + }); + + const applied = applyUserConstraintsToPlaySpec(board); + expect(applied.mounted).toBe(true); + expect(applied.count).toBe(STARTER_PREFERENCES.length + 2); + expect(board.getContentByTag(USER_CONSTRAINTS_TAG)).toContain("精确数字"); + const order = board.getContentByTag("设计.上下文投影排序") ?? ""; + expect(order).toContain(USER_CONSTRAINTS_TAG); + }); + + it("clears mount when all preferences archived", async () => { + const { + listPreferences, + updatePreference, + loadPreferenceStore, + } = await import("../src/preference/store.js"); + const { + USER_CONSTRAINTS_TAG, + applyUserConstraintsToPlaySpec, + } = await import("../src/preference/constraints.js"); + + loadPreferenceStore(); + for (const e of listPreferences()) { + updatePreference(e.id, { status: "archived" }); + } + + const board = new Blackboard(); + board.write({ + tag: USER_CONSTRAINTS_TAG, + content: "旧约束", + source: "test", + }); + board.write({ + tag: "设计.worker集", + content: JSON.stringify({ + play_slots: { gm: true, narrator: false }, + context_order: { + schema: "context-order.v1", + slots: [ + { + ref: "world-simulator", + inserts: [ + { order: 0, ref: "worker.persona", projection: "fixed" }, + { order: 1, ref: USER_CONSTRAINTS_TAG, projection: "full" }, + ], + }, + ], + }, + }), + source: "test", + }); + + const applied = applyUserConstraintsToPlaySpec(board); + expect(applied.mounted).toBe(false); + expect(board.getContentByTag(USER_CONSTRAINTS_TAG)).toBe(""); + const order = board.getContentByTag("设计.上下文投影排序") ?? ""; + expect(order).not.toContain(`"ref": "${USER_CONSTRAINTS_TAG}"`); + }); +});