From 6d4dfd0d8913f207389c5672b73ed8999a5149de Mon Sep 17 00:00:00 2001 From: moranzhi Date: Tue, 25 Aug 2026 00:57:51 +0800 Subject: [PATCH] Run need_generate sampling via batch chance in auditor maintain path. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auditor packets can request generation-rule pools without LLM randomness; results land in 运行.本轮.旁观.生成抽样 and clear each play turn. Co-authored-by: Cursor --- src/runtime/phase-runtime.ts | 40 ++++++++++- src/skills/maintain-need-generate.ts | 102 +++++++++++++++++++++++++++ tests/maintain-need-generate.test.ts | 43 +++++++++++ 3 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 src/skills/maintain-need-generate.ts create mode 100644 tests/maintain-need-generate.test.ts diff --git a/src/runtime/phase-runtime.ts b/src/runtime/phase-runtime.ts index 4863f5f..316fda6 100644 --- a/src/runtime/phase-runtime.ts +++ b/src/runtime/phase-runtime.ts @@ -140,6 +140,10 @@ import { maintainOpsToTablePatch, parseMaintainPacket, } from "../skills/maintain-packet.js"; +import { + MAINTAIN_GENERATE_TAG, + runMaintainNeedGenerateSampling, +} from "../skills/maintain-need-generate.js"; import { mountResidentContextForWorker as writeResidentContextTags, parseResidentContext, @@ -160,6 +164,7 @@ import type { BlackboardItem } from "../types/blackboard.js"; const PLAY_ROUND_TRANSIENT_TAGS = [ SETTLEMENT_TAG, MAINTAIN_TAG, + MAINTAIN_GENERATE_TAG, "运行.本轮.机遇", "运行.本轮.变量变更", ] as const; @@ -1633,7 +1638,12 @@ export class PhaseRuntime { private applyMaintainTableOps(maintainRaw: string, workerId: string): void { const view = parseMaintainPacket(maintainRaw); const ops = view.packet?.table_ops; - if (!view.ok || !ops?.length) return; + if (!view.ok || !ops?.length) { + if (view.ok && view.packet?.need_generate) { + this.applyMaintainNeedGenerateSampling(view.packet.need_generate, workerId); + } + return; + } const current = parseTableDoc( this.blackboard.getContentByTag("变量.当前"), ); @@ -1650,6 +1660,34 @@ export class PhaseRuntime { if (keys.length) { this.onMessage(`[旁观维护] 变量.当前 ← ${keys.join("、")}`); } + if (view.packet?.need_generate) { + this.applyMaintainNeedGenerateSampling(view.packet.need_generate, workerId); + } + } + + /** need_generate:按生成规则合同程序批量抽样,写入 运行.本轮.旁观.生成抽样 */ + private applyMaintainNeedGenerateSampling( + need: { rule_id: string; reason: string }, + workerId: string, + ): void { + const rulesRaw = this.blackboard.getContentByTag("设计.生成规则"); + const result = runMaintainNeedGenerateSampling({ + need, + generationRulesRaw: rulesRaw, + }); + if (!result) { + this.onMessage( + `[旁观维护] need_generate=${need.rule_id}:未找到可抽样的池(请检查设计.生成规则)`, + ); + return; + } + const content = JSON.stringify( + { rule_id: need.rule_id, reason: need.reason, chance: result }, + null, + 2, + ); + this.writeWorkerTagContent(MAINTAIN_GENERATE_TAG, content, workerId); + this.onMessage(`[旁观维护] 生成抽样 ${need.rule_id}:${result.summary}`); } /** 避免 syncSlots 重复把同一句用户输入追加进历史 */ diff --git a/src/skills/maintain-need-generate.ts b/src/skills/maintain-need-generate.ts new file mode 100644 index 0000000..f681828 --- /dev/null +++ b/src/skills/maintain-need-generate.ts @@ -0,0 +1,102 @@ +/** + * 旁观维护 need_generate:按生成规则合同程序抽样(不走 LLM 编随机)。 + */ +import { + executeChanceBatch, + type ChanceBatchRequestItem, + type ChanceBatchResult, +} from "./chance-tools.js"; +import { extractJsonObjectText } from "./worker-set-parse.js"; +import type { MaintainNeedGenerate } from "./maintain-packet.js"; + +export const MAINTAIN_GENERATE_TAG = "运行.本轮.旁观.生成抽样"; + +type PoolEntry = { id?: string; 内容?: string; 权重?: number; weight?: number }; + +function asString(v: unknown): string | undefined { + if (typeof v !== "string") return undefined; + const t = v.trim(); + return t || undefined; +} + +function findRuleBody( + parsed: Record, + ruleId: string, +): Record | undefined { + const body = parsed.正文; + if (!body || typeof body !== "object" || Array.isArray(body)) return undefined; + const rules = (body as Record).rules; + if (!Array.isArray(rules)) return undefined; + for (const row of rules) { + if (!row || typeof row !== "object" || Array.isArray(row)) continue; + const r = row as Record; + const id = + asString(r.rule_id) ?? + asString(r.id) ?? + asString(r.规则id); + if (id === ruleId) return r; + } + return undefined; +} + +/** 从生成规则产物中为指定 rule_id 构建批量随机请求(每个池一条 pick) */ +export function buildChanceBatchFromGenerationRule( + rulesArtifactRaw: string, + ruleId: string, +): ChanceBatchRequestItem[] | null { + const text = extractJsonObjectText(rulesArtifactRaw) ?? rulesArtifactRaw.trim(); + if (!text) return null; + let parsed: Record; + try { + parsed = JSON.parse(text) as Record; + } catch { + return null; + } + const rule = findRuleBody(parsed, ruleId); + if (!rule) return null; + const pools = rule.池 ?? rule.pools; + if (!Array.isArray(pools) || !pools.length) return null; + + const requests: ChanceBatchRequestItem[] = []; + for (const poolRaw of pools) { + if (!poolRaw || typeof poolRaw !== "object" || Array.isArray(poolRaw)) continue; + const pool = poolRaw as Record; + const poolId = + asString(pool.pool_id) ?? asString(pool.池id) ?? asString(pool.名称); + const entries = (pool.条目 ?? pool.entries) as PoolEntry[] | undefined; + if (!poolId || !Array.isArray(entries) || !entries.length) continue; + const items = entries + .map((e, idx) => { + const id = asString(e.id) ?? asString(e.内容) ?? `item-${idx}`; + const weight = + typeof e.权重 === "number" + ? e.权重 + : typeof e.weight === "number" + ? e.weight + : undefined; + return id ? { id, weight } : undefined; + }) + .filter((x): x is { id: string; weight?: number } => Boolean(x)); + if (!items.length) continue; + requests.push({ + id: `pool:${poolId}`, + op: "pick", + items, + count: 1, + unique: true, + reason: `生成规则 ${ruleId} · 池 ${poolId}`, + }); + } + return requests.length ? requests : null; +} + +export function runMaintainNeedGenerateSampling(params: { + need: Exclude; + generationRulesRaw: string | null | undefined; +}): ChanceBatchResult | null { + const raw = params.generationRulesRaw?.trim(); + if (!raw) return null; + const batch = buildChanceBatchFromGenerationRule(raw, params.need.rule_id); + if (!batch?.length) return null; + return executeChanceBatch(batch); +} diff --git a/tests/maintain-need-generate.test.ts b/tests/maintain-need-generate.test.ts new file mode 100644 index 0000000..6031258 --- /dev/null +++ b/tests/maintain-need-generate.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + buildChanceBatchFromGenerationRule, + runMaintainNeedGenerateSampling, +} from "../src/skills/maintain-need-generate.js"; + +const SAMPLE_RULES = JSON.stringify({ + schema: "context-fragment.v1", + 正文: { + rules: [ + { + rule_id: "npc-trait", + 池: [ + { + pool_id: "mood", + 条目: [ + { id: "calm", 权重: 2 }, + { id: "angry", 权重: 1 }, + ], + }, + ], + }, + ], + }, +}); + +describe("maintain need_generate sampling", () => { + it("builds pick requests from generation rule pools", () => { + const batch = buildChanceBatchFromGenerationRule(SAMPLE_RULES, "npc-trait"); + expect(batch).toHaveLength(1); + expect(batch![0]!.id).toBe("pool:mood"); + expect(batch![0]!.op).toBe("pick"); + }); + + it("runs batch sampling for need_generate", () => { + const result = runMaintainNeedGenerateSampling({ + need: { rule_id: "npc-trait", reason: "补一条" }, + generationRulesRaw: SAMPLE_RULES, + }); + expect(result?.schema).toBe("chance.batch.v1"); + expect(result?.results[0]?.id).toBe("pool:mood"); + }); +});