{"repo": "alextangson/autocrew", "files": {"src/modules/learnings/style-distiller.test.ts": {"49ae2f8ffc27af44d6f91d69e477ec55f5c146c6": {"t": 1786068277.7035098, "n": 16703, "h": "eed110a01033be64", "c": "/**\n * style-distiller.test.ts — 全 mock,零网络\n *\n * 假 runLoopImpl 实际调用 submit_rules 工具的 execute 处理器,\n * 这是捕获机制的关键:execute 闭包把 rules 写进外部变量。\n */\nimport { describe, it, expect, beforeEach, afterEach } from \"vitest\";\nimport fs from \"node:fs/promises\";\nimport os from \"node:os\";\nimport path from \"node:path\";\n\nimport {\n distillStyleRules,\n analyzeStyleSamples,\n shouldDistillStyle,\n verifyVerbatimExcerpts,\n fallbackExcerpts,\n} from \"./style-distiller.js\";\nimport { recordDiff, listDiffs } from \"./diff-tracker.js\";\nimport { loadProfile, addWritingRule } from \"../profile/creator-profile.js\";\nimport type { LoopResult, LoopTool, LoopOptions } from \"../../engine/loop.js\";\nimport type { EngineConfig } from \"../../engine/config.js\";\n\n// ─── Helpers ─────────────────────────────────────────────────────────────────\n\nlet testDir: string;\n\nconst ENV_KEYS = [\"DEEPSEEK_API_KEY\", \"DEEPSEEK_BASE_URL\"] as const;\nconst saved: Record = {};\n\nbeforeEach(async () => {\n testDir = await fs.mkdtemp(path.join(os.tmpdir(), \"autocrew-style-test-\"));\n await fs.writeFile(\n path.join(testDir, \"engine.json\"),\n JSON.stringify({ apiKey: \"sk-test\", strongModel: \"m-strong\", fastModel: \"m-fast\" }),\n );\n for (const k of ENV_KEYS) {\n saved[k] = process.env[k];\n delete process.env[k];\n }\n});\n\nafterEach(async () => {\n await fs.rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });\n for (const k of ENV_KEYS) {\n if (saved[k] === undefined) delete process.env[k];\n else process.env[k] = saved[k];\n }\n});\n\n/**\n * Write a distill-state.json with the given ISO timestamp.\n * Pass \"\" to write an empty object (= no previous distillation).\n */\nasync function writeDistillState(lastDistilledAt: string): Promise {\n const dir = path.join(testDir, \"learnings\");\n await fs.mkdir(dir, { recursive: true });\n await fs.writeFile(\n path.join(dir, \"distill-state.json\"),\n JSON.stringify(lastDistilledAt ? { lastDistilledAt } : {}),\n );\n}\n\nasync function readDistillState(): Promise<{ lastDistilledAt?: string }> {\n try {\n const raw = await fs.readFile(path.join(testDir, \"learnings\", \"distill-state.json\"), \"utf-8\");\n return JSON.parse(raw);\n } catch {\n return {};\n }\n}\n\n/**\n * Build a fake runLoopImpl that calls submit_rules with the given rule payloads.\n * Each execute return value is pushed into execResults.\n */\nfunction makeRunLoop(\n ruleBatches: Array>,\n execResults: string[] = [],\n): (_cfg: EngineConfig, opts: LoopOptions) => Promise {\n return async (_cfg, opts) => {\n const submitTool = (opts.tools ?? []).find((t: LoopTool) => t.name === \"submit_rules\");\n if (!submitTool) throw new Error(\"submit_rules tool not found in opts\");\n\n for (const rules of ruleBatches) {\n execResults.push(await submitTool.execute({ rules }));\n }\n\n return {\n finalMessage: \"规则已提交\",\n turns: ruleBatches.length + 1,\n totalTokens: 100,\n toolCallCount: ruleBatches.length,\n stopReason: \"no_tool_calls\",\n } satisfies LoopResult;\n };\n}\n\n/**\n * Variant that also exposes callCount.\n */\nfunction makeRunLoopWithCount(\n ruleBatches: Array>,\n): {\n impl: (_cfg: EngineConfig, opts: LoopOptions) => Promise;\n getCallCount: () => number;\n} {\n let callCount = 0;\n const impl = async (_cfg: EngineConfig, opts: LoopOptions): Promise => {\n callCount++;\n const submitTool = (opts.tools ?? []).find((t: LoopTool) => t.name === \"submit_rules\");\n if (!submitTool) throw new Error(\"submit_rules tool not found in opts\");\n\n for (const rules of ruleBatches) {\n await submitTool.execute({ rules });\n }\n\n return {\n finalMessage: \"规则已提交\",\n turns: ruleBatches.length + 1,\n totalTokens: 100,\n toolCallCount: ruleBatches.length,\n stopReason: \"no_tool_calls\",\n } satisfies LoopResult;\n };\n return { impl, getCallCount: () => callCount };\n}\n\nconst GOOD_RULES = [\n { rule: \"用口语化短句,避免书面长句\", evidence: \"before: 鉴于...因此 after: 说白了...\", confidence: 0.9 },\n { rule: \"段落不超过三行\", evidence: \"before: 5行段落 after: 2行段落\", confidence: 0.8 },\n];\n\n// ─── Tests ────────────────────────────────────────────────────────────────────\n\ndescribe(\"distillStyleRules\", () => {\n // 1. Happy path\n it(\"happy path: 2 new diffs + 2 rules → profile updated, state written, summary correct\", async () => {\n // Record 2 diffs\n await recordDiff(\"c1\", \"body\", \"鉴于某某原因,因此可以考虑\", \"说白了,直接做就行\", testDir);\n await recordDiff(\"c1\", \"body\", \"首先其次最后罗列\", \"三点建议:一 二 三\", testDir);\n\n const runLoopImpl = makeRunLoop([GOOD_RULES]);\n const result = await distillStyleRules(testDir, { runLoopImpl });\n\n expect(result.newRules).toHaveLength(2);\n expect(result.diffsAnalyzed).toBe(2);\n expect(result.skippedDuplicates).toBe(0);\n expect(result.summary).toContain(\"2\");\n\n // Rules written to profile\n const profile = await loadProfile(testDir);\n expect(profile!.writingRules).toHaveLength(2);\n expect(profile!.writingRules.every((r) => r.source === \"auto_distilled\")).toBe(true);\n expect(profile!.writingRules[0].confidence).toBe(0.9);\n\n // State written\n const state = await readDistillState();\n expect(state.lastDistilledAt).toBeTruthy();\n });\n\n // 2. Self-correction: confidence out of range → error, then valid → success\n it(\"self-correction: confidence 1.5 → execute returns error, valid retry succeeds\", async () => {\n await recordDiff(\"c1\", \"body\", \"before text\", \"after text\", testDir);\n\n const execResults: string[] = [];\n const badRules = [{ rule: \"测试规则\", evidence: \"证据\", confidence: 1.5 }];\n const runLoopImpl: (_cfg: EngineConfig, opts: LoopOptions) => Promise = async (\n _cfg,\n opts,\n ) => {\n const submitTool = (opts.tools ?? []).find((t: LoopTool) => t.name === \"submit_rules\");\n if (!submitTool) throw new Error(\"submit_rules tool not found\");\n\n // First call rejected (out-of-range confidence), second call valid — self-correction channel\n execResults.push(await submitTool.execute({ rules: badRules }));\n execResults.push(await submitTool.execute({ rules: GOOD_RULES }));\n\n return {\n finalMessage: \"done\",\n turns: 3,\n totalTokens: 120,\n toolCallCount: 2,\n stopReason: \"no_tool_calls\",\n } satisfies LoopResult;\n };\n\n const result = await distillStyleRules(testDir, { runLoopImpl });\n\n expect(execResults[0]).toContain(\"请修正\");\n expect(result.newRules).toHaveLength(2);\n });\n\n // 3. Exact-duplicate guard: rule matches existing → skippedDuplicates=1\n it(\"exact duplicate: 1 rule same as existing → skippedDuplicates=1, not re-added\", async () => {\n await recordDiff(\"c1\", \"body\", \"before\", \"after\", testDir);\n // Pre-seed profile with the same rule text\n const existingRule = GOOD_RULES[0].rule;\n await addWritingRule({ rule: existingRule, source: \"user_explicit\", confidence: 1 }, testDir);\n\n const runLoopImpl = makeRunLoop([GOOD_RULES]); // submits 2 rules, 1 is duplicate\n const result = await distillStyleRules(testDir, { runLoopImpl });\n\n expect(result.skippedDuplicates).toBe(1);\n expect(result.newRules).toHaveLength(1);\n\n const profile = await loadProfile(testDir);\n // 1 pre-existing + 1 new = 2 total\n expect(profile!.writingRules).toHaveLength(2);\n });\n\n // 3b. Evidence attribution: duplicate dropped mid-array must not shift evidences (I1)\n it(\"evidence attribution: [dup, 新A, 新B] → summary pairs each new rule with its own evidence\", async () => {\n await recordDiff(\"c1\", \"body\", \"before\", \"after\", testDir);\n const dupRule = \"已有规则:保持简短\";\n await addWritingRule({ rule: dupRule, source: \"user_explicit\", confidence: 1 }, testDir);\n\n const submitted = [\n { rule: dupRule, evidence: \"重复证据\", confidence: 0.9 },\n { rule: \"新规则A:开头用提问\", evidence: \"证据A:before陈述 after提问\", confidence: 0.8 },\n { rule: \"新规则B:结尾留钩子\", evidence: \"证据B:after新增悬念句\", confidence: 0.7 },\n ];\n const runLoopImpl = makeRunLoop([submitted]);\n const result = await distillStyleRules(testDir, { runLoopImpl });\n\n expect(result.skippedDuplicates).toBe(1);\n expect(result.newRules).toHaveLength(2);\n expect(result.summary).toContain(\"新规则A:开头用提问(依据:证据A:before陈述 after提问)\");\n expect(result.summary).toContain(\"新规则B:结尾留钩子(依据:证据B:after新增悬念句)\");\n expect(result.summary).not.toContain(\"重复证据\");\n });\n\n // 2b. NaN confidence must be rejected at the trust boundary (I2)\n it(\"confidence NaN → execute returns 自纠 error\", async () => {\n await recordDiff(\"c1\", \"body\", \"before\", \"after\", testDir);\n\n const execResults: string[] = [];\n const nanRules = [{ rule: \"测试规则\", evidence: \"证据\", confidence: NaN }];\n const runLoopImpl = makeRunLoop([nanRules, GOOD_RULES], execResults);\n const result = await distillStyleRules(testDir, { runLoopImpl });\n\n expect(execResults[0]).toContain(\"请修正\");\n expect(result.newRules).toHaveLength(2);\n });\n\n // 4. No new diffs → model not called, state unchanged\n it(\"no new diffs → model not called (callCount=0), state unchanged\", async () => {\n // Record a diff, then set lastDistilledAt in the future so it counts as already distilled\n await recordDiff(\"c1\", \"body\", \"before\", \"after\", testDir);\n await writeDistillState(new Date(Date.now() + 60000).toISOString());\n\n const { impl: runLoopImpl, getCallCount } = makeRunLoopWithCount([]);\n const result = await distillStyleRules(testDir, { runLoopImpl });\n\n expect(getCallCount()).toBe(0);\n expect(result.newRules).toHaveLength(0);\n expect(result.diffsAnalyzed).toBe(0);\n // State should remain as-is (not updated)\n const state = await readDistillState();\n expect(state.lastDistilledAt).toBeTruthy();\n });\n\n // 5. Truncation: 5 rules submitted → only 3 stored\n it(\"truncation: fake submits 5 rules → only 3 stored\", async () => {\n await recordDiff(\"c1\", \"body\", \"before\", \"after\", testDir);\n\n const fiveRules = Array.from({ length: 5 }, (_, i) => ({\n rule: `规则${i + 1}:具体要求${i + 1}`,\n evidence: `证据${i + 1}`,\n confidence: 0.8,\n }));\n const runLoopImpl = makeRunLoop([fiveRules]);\n const result = await distillStyleRules(testDir, { runLoopImpl });\n\n expect(result.newRules).toHaveLength(3);\n const profile = await loadProfile(testDir);\n expect(profile!.writingRules).toHaveLength(3);\n });\n\n // 6. Never submits → throws, state not updated\n it(\"never submits → throws, state not updated\", async () => {\n await recordDiff(\"c1\", \"body\", \"before\", \"after\", testDir);\n\n const runLoopImpl: (_cfg: EngineConfig, opts: LoopOptions) => Promise = async () => {\n return {\n finalMessage: \"不想调工具\",\n turns: 3,\n totalTokens: 50,\n toolCallCount: 0,\n stopReason: \"max_turns\",\n } satisfies LoopResult;\n };\n\n await expect(distillStyleRules(testDir, { runLoopImpl })).rejects.toThrow(\n /submit_rules|未提交|规则/,\n );\n\n // State must not be written\n const state = await readDistillState();\n expect(state.lastDistilledAt).toBeUndefined();\n });\n\n // 7. shouldDistillStyle: 2 new diffs → false; 3 new diffs → true\n it(\"shouldDistillStyle: 2 diffs → false; 3 diffs → true\", async () => {\n await recordDiff(\"c1\", \"body\", \"before\", \"after\", testDir);\n await recordDiff(\"c2\", \"body\", \"before\", \"after\", testDir);\n expect(await shouldDistillStyle(testDir)).toBe(false);\n\n await recordDiff(\"c3\", \"body\", \"before\", \"after\", testDir);\n expect(await shouldDistillStyle(testDir)).toBe(true);\n });\n});\n\ndescribe(\"analyzeStyleSamples\", () => {\n // 8. Happy path: samples analyzed, no state change\n it(\"happy path: 2 samples → rules returned, distill-state not written\", async () => {\n const runLoopImpl = makeRunLoop([GOOD_RULES]);\n const result = await analyzeStyleSamples(\n [\"爆款样本1:今天分享三个绝绝子的用法\", \"爆款样本2:你们有没有发现这个细节\"],\n testDir,\n { runLoopImpl },\n );\n\n expect(result.newRules).toHaveLength(2);\n expect(result.diffsAnalyzed).toBe(2); // samplesAnalyzed reuses diffsAnalyzed field\n expect(result.summary).toBeTruthy();\n\n // distill-state must NOT be touched\n const state = await readDistillState();\n expect(state.lastDistilledAt).toBeUndefined();\n });\n\n // 9. Empty samples → throws parameter error\n it(\"empty samples array → throws parameter error\", async () => {\n const runLoopImpl = makeRunLoop([]);\n await expect(analyzeStyleSamples([], testDir, { runLoopImpl })).rejects.toThrow(\n /samples|样本/i,\n );\n });\n\n // 10. Calibration samples always land as voice_core, even if the model tags a platform\n it(\"forces voice_core scope on sample-derived rules (§4.3: 校准样本 = 声音内核种子)\", async () => {\n const scoped = [\n { rule: \"标题带数字\", evidence: \"样本1标题\", confidence: 0.9, scope: \"platform:wechat_mp\" },\n ];\n const runLoopImpl = makeRunLoop([scoped]);\n await analyzeStyleSamples([\"样本正文\"], testDir, { runLoopImpl });\n\n const profile = JSON.parse(await fs.readFile(path.join(testDir, \"creator-profile.json\"), \"utf-8\"));\n expect(profile.writingRules[0].scope).toBe(\"voice_core\");\n });\n});\n\ndescribe(\"voice excerpts (V5.7 声音样本)\", () => {\n const SAMPLE =\n \"创业者找我聊的时候,十有八九第一句话是问该买哪个课。我的答案从来只有一个:先别买,把你上周真实卡住的那个问题写下来。\\n\\n第二段讲方法论,这里的内容也足够长,超过二十个字没有问题。\";\n\n function makeRunLoopWithExcerpts(\n rules: Array<{ rule: string; evidence: string; confidence: number }>,\n excerpts: string[],\n ): (_cfg: EngineConfig, opts: LoopOptions) => Promise {\n return async (_cfg, opts) => {\n const submitTool = (opts.tools ?? []).find((t: LoopTool) => t.name === \"submit_rules\");\n if (!submitTool) throw new Error(\"submit_rules tool not found\");\n await submitTool.execute({ rules, excerpts });\n return { finalMessage: \"done\", turns: 2, totalTokens: 100, toolCallCount: 1, stopReason: \"no_tool_calls\" } satisfies LoopResult;\n };\n }\n\n it(\"persists verbatim excerpts, drops fabricated ones\", async () => {\n const verbatim = \"先别买,把你上周真实卡住的那个问题写下来。\";\n const fabricated = \"这一段是模型自己编的,样本里根本没有这句话,超过二十个字。\";\n const runLoopImpl = makeRunLoopWithExcerpts(GOOD_RULES, [verbatim, fabricated]);\n\n const result = await analyzeStyleSamples([SAMPLE], testDir, { runLoopImpl });\n\n const profile = await loadProfile(testDir);\n expect(profile!.voiceSamples).toContain(verbatim);\n expect(profile!.voiceSamples).not.toContain(fabricated);\n expect(result.voiceSamplesSaved).toBeGreaterThan(0);\n expect(result.summary).toContain(\"声音样本\");\n });\n\n it(\"falls back to first paragraphs when model submits no usable excerpts\", async () => {\n const runLoopImpl = makeRunLoop([GOOD_RULES]);\n await analyzeStyleSamples([SAMPLE], testDir, { runLoopImpl });\n\n const profile = await loadProfile(testDir);\n expect(profile!.voiceSamples!.length).toBeGreaterThan(0);\n expect(profile!.voiceSamples![0]).toContain(\"创业者找我聊的时候\");\n });\n\n it(\"verifyVerbatimExcerpts: tolerates whitespace differences, rejects short/foreign text\", () => {\n const samples = [\"第一句话。第二句话继续继续继续继续继续继续。\"];\n const withNewline = \"第一句话。\\n第二句话继续继续继续继续继续继续。\";\n expect(verifyVerbatimExcerpts([withNewline], samples)).toEqual([withNewline]);\n expect(verifyVerbatimExcerpts([\"太短\"], samples)).toEqual([]);\n expect(verifyVerbatimExcerpts([\"完全不在样本里的一段文字并且长度超过二十个字符啊\"], samples)).toEqual([]);\n });\n\n it(\"fallbackExcerpts: takes first paragraph ≥20 chars, slices to 200\", () => {\n const long = \"长\".repeat(300);\n const out = fallbackExcerpts([`短段。\\n\\n${long}`], 2);\n expect(out).toHaveLength(1);\n expect(out[0]).toHaveLength(200);\n });\n});\n\ndescribe(\"scope routing through distillStyleRules\", () => {\n it(\"persists the scope submitted by the model (纠正记入发生平台)\", async () => {\n await recordDiff(\"c1\", \"body\", \"旧正文\", \"新正文\", testDir, undefined, \"wechat_mp\");\n\n const scoped = [\n { rule: \"公众号正文空行分段\", evidence: \"diff1\", confidence: 0.8, scope: \"platform:wechat_mp\" },\n ];\n const runLoopImpl = makeRunLoop([scoped]);\n const result = await distillStyleRules(testDir, { runLoopImpl });\n\n expect(result.newRules).toHaveLength(1);\n expect(result.newRules[0].scope).toBe(\"platform:wechat_mp\");\n });\n\n it(\"rejects malformed scope via submit_rules self-correction\", async () => {\n await recordDiff(\"c1\", \"body\", \"旧\", \"新\", testDir);\n\n const bad = [{ rule: \"规则\", evidence: \"e\", confidence: 0.5, scope: \"wechat_mp\" }];\n const execResults: string[] = [];\n const runLoopImpl = makeRunLoop([bad, GOOD_RULES], execResults);\n await distillStyleRules(testDir, { runLoopImpl });\n\n expect(execResults[0]).toContain(\"scope\");\n });\n});\n"}}}}