Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
19 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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<string, string | undefined> = {};\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<void> {\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<Array<{ rule: string; evidence: string; confidence: number; scope?: string }>>,\n execResults: string[] = [],\n): (_cfg: EngineConfig, opts: LoopOptions) => Promise<LoopResult> {\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<Array<{ rule: string; evidence: string; confidence: number }>>,\n): {\n impl: (_cfg: EngineConfig, opts: LoopOptions) => Promise<LoopResult>;\n getCallCount: () => number;\n} {\n let callCount = 0;\n const impl = async (_cfg: EngineConfig, opts: LoopOptions): Promise<LoopResult> => {\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<LoopResult> = 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<LoopResult> = 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<LoopResult> {\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"}}}}