From e2c134ffa09aac42a1de836cfcc452e825d0abe5 Mon Sep 17 00:00:00 2001 From: bufan <178316470@qq.com> Date: Fri, 15 May 2026 15:53:58 +0800 Subject: [PATCH] feat(diversity): per-platform Layer-A state, exhaustion warnings, grow-corpus.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coordinated changes to make the corpus-direct diversity system persistent and infinitely extensible across batches: 1. Per-platform Layer-A usage state (run-corpus.js) - Default path now corpus_usage_{platform}.json (was corpus_usage.json) - Platform is resolved before usageStatePath so mobile and web never write into each other's state; aligns with the per-platform files already written by merge-subagent-retry.js 2. Exhaustion warnings (query_factory_v2.js) - pickLeastUsedTopics() emits [Layer-A WARN] with l2Key when count > pool_size (cycling is about to start) - buildCorpusPlan() emits a pre-call warning when all topics in an L2 have been used at least once 3. grow-corpus.js — new script for LLM-based topic pool expansion - Scans corpus_data[_web].json for L2s with --- data/state/corpus_usage_mobile.json | 2 +- docs/index.html | 26 ++-- mvp/query_factory_v2.js | 20 ++- scripts/README.md | 185 ++++++++++++++++++++++----- scripts/grow-corpus.js | 192 ++++++++++++++++++++++++++++ scripts/run-corpus.js | 11 +- 6 files changed, 384 insertions(+), 52 deletions(-) create mode 100644 scripts/grow-corpus.js diff --git a/data/state/corpus_usage_mobile.json b/data/state/corpus_usage_mobile.json index e0ae5f8..e4148f0 100644 --- a/data/state/corpus_usage_mobile.json +++ b/data/state/corpus_usage_mobile.json @@ -1,6 +1,6 @@ { "version": 1, - "last_updated": "2026-05-15T07:01:20.311Z", + "last_updated": "2026-05-15T07:22:50.645Z", "usage": { "① 个人生活类": { "Travel Memory Scrapbook": 2, diff --git a/docs/index.html b/docs/index.html index 8e6916e..c5652f7 100644 --- a/docs/index.html +++ b/docs/index.html @@ -885,9 +885,11 @@

Diversity through real-world gro
Layer-A · Least-used topic sampling
Naive topics[i % length] rotation makes consecutive batches pick identical - corpus topics. We persist data/state/corpus_usage.json — usage counts per - (l2_key, topic) — and prefer least-used topics for each new run. 100% → 0% topic - overlap with previous batch. + corpus topics. We persist per-platform state — corpus_usage_mobile.json / + corpus_usage_web.json — usage counts per (l2_key, topic) — and prefer + least-used topics for each new run. 100% → 0% topic overlap with previous batch. + When a pool is fully exhausted, [Layer-A WARN] fires and + grow-corpus.js can expand it.
pickLeastUsedTopics(topics, n, usageMap)
@@ -947,7 +949,9 @@

Diversity through real-world gro Bootstrap from existing run · first time enabling Layer-A, seed the state file from a historical queries.jsonl + plan.jsonl pair. Subsequent runs auto-update on success; - failed rows are not counted toward usage. Inspect data/state/corpus_usage.json any time. + failed rows are not counted toward usage. State is per-platform: + data/state/corpus_usage_mobile.json / corpus_usage_web.json. + When a pool is exhausted, run node scripts/grow-corpus.js --platform <p> to expand it.

@@ -1135,7 +1139,7 @@

Iteration story: 4 stages, 4 fix
FIX
-
Layer-A least-used topic dedup across batches via corpus_usage.json state · 5-bucket opener hash (Build a / Need a / Create a / Make a / no opener) keyed by query_id · persona-tone semantic mapping from L2 → 5 ordinary-user archetypes.
+
Layer-A least-used topic dedup across batches via per-platform corpus_usage_{platform}.json state · 5-bucket opener hash (Build a / Need a / Create a / Make a / no opener) keyed by query_id · persona-tone semantic mapping from L2 → 5 ordinary-user archetypes.
RESULT
@@ -1361,7 +1365,7 @@

What's inside

"ev2.s1.p":"No persona, no opener distribution, no scope discipline. Same first-N corpus topics get reused across batches; \"Build a mobile X\" pattern dominates; query framed as a single page or screen, not a 0-to-1 app.", "ev2.s1.sumr":"Sample query (illustrative)", "ev2.s2.t":"Three-layer diversification", - "ev2.s2.f":"Layer-A least-used topic dedup across batches via corpus_usage.json state · 5-bucket opener hash (Build a / Need a / Create a / Make a / no opener) keyed by query_id · persona-tone semantic mapping from L2 → 5 ordinary-user archetypes.", + "ev2.s2.f":"Layer-A least-used topic dedup across batches via per-platform corpus_usage_{platform}.json state · 5-bucket opener hash (Build a / Need a / Create a / Make a / no opener) keyed by query_id · persona-tone semantic mapping from L2 → 5 ordinary-user archetypes.", "ev2.s2.r":"Cross-batch corpus-topic overlap: 100% → 0%. \"Build a\" share: 54% → 21%. 5 distinct persona voices visible in batch.", "ev2.s2.p":"Audit found 49.5% of queries still framed as \"Build a XX page where…\" — the opener is now diverse but the scope noun is still page-level, causing downstream LLMs to generate single-page mocks instead of complete apps.", "ev2.s3.t":"App-scope rewrite", @@ -1415,7 +1419,7 @@

What's inside

"div.f2.d":"Each query is compared against its same-scene peers via trigram-set Jaccard similarity. The diversity score rewards low max-peer-similarity.", "div.f2.r1":"distinct enough","div.f2.r2":"strongly distinct", "div.f3.t":"Layer-A · Least-used topic sampling", - "div.f3.d":"Naive topics[i % length] rotation makes consecutive batches pick identical corpus topics. We persist data/state/corpus_usage.json — usage counts per (l2_key, topic) — and prefer least-used topics for each new run. 100% → 0% topic overlap with previous batch.", + "div.f3.d":"Naive topics[i % length] rotation makes consecutive batches pick identical corpus topics. We persist per-platform state — corpus_usage_mobile.json / corpus_usage_web.json — usage counts per (l2_key, topic) — and prefer least-used topics for each new run. 100% → 0% topic overlap with previous batch. When a pool is fully exhausted, [Layer-A WARN] fires and grow-corpus.js can expand it.", "div.f3.r1":"topic overlap with prior batch", "div.f4.t":"Opener hash distribution", "div.f4.d":"With \"mobile H5\" in the system prompt, 54% of v3 outputs converged on Build a mobile .... We deterministically hash query_id into one of 5 opener buckets (Build a / Need a / Create a / Make a / no formal opener), forcing uniform distribution. Idempotent across reruns.", @@ -1428,7 +1432,7 @@

What's inside

"div.f5.r4":"个人专业 · 海报 / 简历模板", "div.f5.r5":"长尾微工具 · 经典小游戏 · Adding & Creating", "div.bs.t":"Bootstrap from existing run", - "div.bs.d":"first time enabling Layer-A, seed the state file from a historical queries.jsonl + plan.jsonl pair. Subsequent runs auto-update on success; failed rows are not counted toward usage. Inspect data/state/corpus_usage.json any time.", + "div.bs.d":"first time enabling Layer-A, seed the state file from a historical queries.jsonl + plan.jsonl pair. Subsequent runs auto-update on success; failed rows are not counted toward usage. State is per-platform: data/state/corpus_usage_mobile.json / corpus_usage_web.json. When a pool is exhausted, run node scripts/grow-corpus.js --platform <p> to expand it.", "div.tab.bars":"// L1 distribution", "div.tab.network":"// Corpus network", "div.cv.note":"Each ⬤ inner node is an L1 category; · outer dot is an L2 sub-scene. Hover to highlight a branch · click any node to drill in.", @@ -1483,7 +1487,7 @@

What's inside

"ev2.s1.p":"无 persona、无 opener 分布、无 scope 纪律。批次之间反复使用前 N 个相同的 corpus topic;「Build a mobile X」模板占绝对主导;query 框定为单个 page/screen,而非 0-to-1 的完整 app。", "ev2.s1.sumr":"样例 query(示意)", "ev2.s2.t":"三层差异化", - "ev2.s2.f":"Layer-A 跨批次最少使用 topic 优先(持久化到 corpus_usage.json)· 5 桶 opener 哈希(Build a / Need a / Create a / Make a / 无 opener)按 query_id 决定性分配 · persona-tone 按 L2 语义最佳匹配映射到 5 个普通用户 archetype。", + "ev2.s2.f":"Layer-A 跨批次最少使用 topic 优先(按平台持久化到 corpus_usage_{platform}.json)· 5 桶 opener 哈希(Build a / Need a / Create a / Make a / 无 opener)按 query_id 决定性分配 · persona-tone 按 L2 语义最佳匹配映射到 5 个普通用户 archetype。", "ev2.s2.r":"跨批次 corpus_topic 重叠率:100% → 0%。「Build a」开头占比:54% → 21%。批次内 5 种 persona 口吻清晰可辨。", "ev2.s2.p":"审计发现 49.5% 的 query 仍然以「Build a XX page where…」框定 —— opener 已分散,但 scope 名词仍在 page 级,导致下游 LLM 生成单页 mock 而非完整 app。", "ev2.s3.t":"App-scope 改写", @@ -1537,7 +1541,7 @@

What's inside

"div.f2.d":"每条 query 与同场景内的同辈做 trigram 集合的 Jaccard 相似度比较;多样性分鼓励低的 max-peer-similarity。", "div.f2.r1":"足够独特","div.f2.r2":"明显独特", "div.f3.t":"Layer-A · 最少使用 topic 优先采样", - "div.f3.d":"朴素的 topics[i % length] 轮换会让相邻批次反复挑到同样的 corpus topic。我们把每个 (l2_key, topic) 的累计使用次数持久化到 data/state/corpus_usage.json,新批次优先选最少使用过的 topic。100% → 0% 跨批次 topic 重叠。", + "div.f3.d":"朴素的 topics[i % length] 轮换会让相邻批次反复挑到同样的 corpus topic。我们按平台分别持久化 usage state(corpus_usage_mobile.json / corpus_usage_web.json),记录每个 (l2_key, topic) 的累计使用次数,新批次优先选最少使用过的 topic。100% → 0% 跨批次 topic 重叠。topic 池耗尽时触发 [Layer-A WARN],可用 grow-corpus.js 扩池。", "div.f3.r1":"与上一批 topic 重叠率", "div.f4.t":"开头哈希均匀分配", "div.f4.d":"system prompt 里有 \"mobile H5\",v3 输出 54% 都收敛到 Build a mobile ...。我们用 query_id 决定性哈希到 5 个开头桶之一(Build a / Need a / Create a / Make a / 无 formal opener),强制均匀分布;同 query_id 跑多次拿到同一开头。", @@ -1550,7 +1554,7 @@

What's inside

"div.f5.r4":"个人专业 · 海报 / 简历模板", "div.f5.r5":"长尾微工具 · 经典小游戏 · Adding & Creating", "div.bs.t":"从历史 run 引导 state", - "div.bs.d":"首次启用 Layer-A 时,可从已有 queries.jsonl + plan.jsonl 一次性导入历史 usage。后续 run 跑成功的条目自动累加;失败的不计入。任何时候打开 data/state/corpus_usage.json 即可查看当前状态。", + "div.bs.d":"首次启用 Layer-A 时,可从已有 queries.jsonl + plan.jsonl 一次性导入历史 usage。后续 run 跑成功的条目自动累加;失败的不计入。State 按平台隔离:data/state/corpus_usage_mobile.json / corpus_usage_web.json。topic 池耗尽后,运行 node scripts/grow-corpus.js --platform <p> 扩池。", "div.tab.bars":"// L1 分布", "div.tab.network":"// 语料网络", "div.cv.note":"⬤ 内圈节点 是 L1 类目;· 外圈点 是 L2 子场景。悬停高亮分支 · 点击任意节点下钻查看。", diff --git a/mvp/query_factory_v2.js b/mvp/query_factory_v2.js index 6711d46..99105de 100644 --- a/mvp/query_factory_v2.js +++ b/mvp/query_factory_v2.js @@ -2611,7 +2611,7 @@ function allocateCorpusCountsByScenePlan(spec, total, options = {}) { * @param {object} usageMap - { topic: usageCount }; missing key counts as 0 * @returns {string[]} - picked topics, length === count */ -function pickLeastUsedTopics(topics, count, usageMap = {}) { +function pickLeastUsedTopics(topics, count, usageMap = {}, label = "") { if (!topics.length) return []; // Annotate with original index for stable tie-break const annotated = topics.map((topic, idx) => ({ @@ -2621,6 +2621,15 @@ function pickLeastUsedTopics(topics, count, usageMap = {}) { })); // Sort: least-used first, then original index annotated.sort((a, b) => (a.use - b.use) || (a.idx - b.idx)); + // Warn when pool is exhausted and cycling will start + if (count > topics.length) { + const minUse = annotated[0].use; + const tag = label ? ` [${label}]` : ""; + console.warn( + `[Layer-A WARN]${tag} topic pool exhausted: requested ${count} but pool has only ${topics.length}` + + ` (min usage=${minUse}). Topics will cycle — consider running grow-corpus to expand the pool.` + ); + } // Cycle if count > topics.length const picked = []; for (let i = 0; i < count; i += 1) { @@ -2723,7 +2732,14 @@ function buildCorpusPlan(spec, corpusData, options = {}) { // Layer-A: pick topics by least-used-first across batches. // Tracks intra-batch increments locally so duplicate picks within this run are also avoided. const localUsage = { ...(corpusUsage[l2Key] || {}) }; - const pickedTopics = pickLeastUsedTopics(topics, count, localUsage); + const usedCount = Object.keys(localUsage).length; + if (topics.length > 0 && usedCount >= topics.length) { + console.warn( + `[Layer-A WARN] [${l2Key}] all ${topics.length} topics already used at least once` + + ` (used=${usedCount}, need=${count}). Next batch will reuse topics.` + ); + } + const pickedTopics = pickLeastUsedTopics(topics, count, localUsage, l2Key); for (let i = 0; i < count; i += 1) { const groupIndex = Math.floor(i / Math.max(MN, 1)); diff --git a/scripts/README.md b/scripts/README.md index cf8d65d..4b03bcb 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -81,56 +81,52 @@ npm run run:free -- --persona-scope task --no-resume **工作流(与 `run:free` 并存的独立入口):** ``` -parseRequirementsFromWorkbook(xlsx) # 解析 61 个 L2 +parseRequirementsFromWorkbook(xlsx) # 解析场景(mobile: xlsx;web: JSON spec) ↓ -buildCorpusPlan(spec, corpus, { # 按 xlsx L1 配比分配 N 个 task - total: 200, # 总量(必填默认) - complexityMix: ["medium"], # 复杂度轮换(默认全 medium) +buildCorpusPlan(spec, corpus, { # 按场景配比分配 N 个 task;Layer-A 最少使用采样 + total: 500, + complexityMix: ["medium"], + corpusUsage: loadCorpusUsage(...) # data/state/corpus_usage_{platform}.json }) ↓ -buildCorpusDirectQueryPrompt(task) # 显式锁 corpus_topic,禁招呼语 +buildCorpusDirectQueryPrompt(task) # 显式锁 corpus_topic;Layer-B opener hash;Layer-C persona 注入 ↓ claude CLI subprocess(lib/claude-cli.js) # 走 packy CC 网关,model=claude-sonnet-4-6 - ↓ -scoreQueryRecord # 启发式打分 + ↓ # —— 或 --prep-only 模式:跳过此步,写 _subagent_in/ —— +scoreQueryRecord # 启发式打分(--score 开启) ↓ data/output/corpus_run/ - ├── plan.jsonl # 完整计划(每 task 含 corpus_topic) - ├── queries.jsonl # 每条 query + score + word_count + duration - └── summary.json # 汇总:L1 分布 / 平均质量 / 通过率 / 耗时 + ├── plan.jsonl # 完整计划(每 task 含 corpus_topic + corpus_l2_key) + ├── queries.jsonl # 每条 query + word_count + duration(+ score 如启用) + ├── queries.xlsx # 同内容 Excel(默认生成) + ├── summary.json # 汇总:L1 分布 / 平均质量 / 通过率 / 耗时 / platform + └── _subagent_in/ # --prep-only 时:subagent 批次输入文件 + └── {platform}_b01_in.json … ``` **用法:** ```bash -# 默认:200 task,全 medium,按 xlsx L1 配比分布 +# 默认:200 task,全 medium,mobile 平台 node scripts/run-corpus.js -# 自定义总量 -node scripts/run-corpus.js --total 500 +# 生产批次:500 条 web 端 +node scripts/run-corpus.js --platform web --total 500 --out data/output/corpus_run_web_500 # 自定义复杂度 mix node scripts/run-corpus.js --total 200 --complexity-mix "vague,medium,medium" # 验证 plan 分布(不调 LLM) -node scripts/run-corpus.js --total 200 --dry-run - -# 验证用:只跑前 N 条真实 LLM -node scripts/run-corpus.js --total 200 --limit 5 +node scripts/run-corpus.js --total 500 --dry-run # 提高并发 -node scripts/run-corpus.js --total 200 --concurrency 4 - -# 自定义输出 -node scripts/run-corpus.js --total 200 --out data/output/corpus_v1 +node scripts/run-corpus.js --total 500 --concurrency 8 # 排除 L1 场景(子串匹配,逗号分隔) node scripts/run-corpus.js --total 200 --exclude-l1 "深度研究,购物消费" -# 自定义 Layer-A state / persona-map 路径 -node scripts/run-corpus.js --total 200 \ - --usage-state data/state/run_alpha.json \ - --persona-map scripts/corpus_persona_map.json +# No-API 模式:只生成 subagent 批次文件,不调 LLM(详见下方「No-API 模式」章节) +node scripts/run-corpus.js --platform mobile --total 500 --prep-only --out data/output/corpus_run_mobile_500 # 关掉 usage 跟踪(一次性试跑、不污染历史) node scripts/run-corpus.js --total 200 --no-usage-track @@ -140,26 +136,149 @@ node scripts/run-corpus.js --total 200 --no-usage-track | 参数 | 默认 | 说明 | |---|---|---| -| `--total` | `200` | 总 task 数;按 xlsx L1 配比缩放分配 | +| `--platform` | `mobile` | 平台:`mobile`(xlsx 场景 + `corpus_data.json`)或 `web`(JSON spec + `corpus_data_web.json`) | +| `--total` | `200` | 总 task 数;按场景配比缩放分配 | | `--complexity-mix` | `"medium"` | 复杂度轮换(逗号分隔),如 `"vague,medium,medium"` | | `--concurrency` | `2` | claude CLI 子进程并发数 | | `--dry-run` | 关 | 不调 LLM,验证 plan 分布与脚本结构 | -| `--limit N` | 关 | 仅执行前 N 个 task | -| `--input` | 自动 | xlsx 路径(默认从 `data/input/` 自动检测) | +| `--limit N` | 关 | 仅执行前 N 个 task(调试用) | +| `--input` | 自动 | xlsx 路径(mobile 平台;默认从 `data/input/` 自动检测) | | `--out` | `data/output/corpus_run` | 输出目录 | -| `--exclude-l1` | 无 | L1 场景子串过滤(逗号分隔),如 `"深度研究,购物消费"` | -| `--usage-state` | `data/state/corpus_usage.json` | Layer-A 跨批次 topic 去重 state 文件 | +| `--exclude-l1` | 无 | L1 场景子串过滤(逗号分隔) | +| `--usage-state` | `data/state/corpus_usage_{platform}.json` | Layer-A 跨批次 topic 去重 state;不同平台自动隔离 | | `--no-usage-track` | 关 | 关闭 Layer-A 跟踪(一次性试跑、不污染历史)| -| `--persona-map` | `scripts/corpus_persona_map.json` | Layer-C L2 → persona 语义映射文件 | +| `--persona-map` | `scripts/corpus_persona_map[_web].json` | Layer-C L2 → persona 语义映射文件 | +| `--prep-only` | 关 | No-API 模式:只写 subagent 批次文件,不调 LLM(见下方章节) | +| `--prep-batch` | `25` | `--prep-only` 每批次 task 数 | +| `--score` | 关 | 开启启发式质量评分 | +| `--no-xlsx` | 关 | 跳过 xlsx 导出 | **三层多样性机制(默认全部启用):** -- **Layer-A 跨批次去重**:`data/state/corpus_usage.json` 记录 `(l2_key, topic)` 累计使用次数;新批次优先选 least-used,与历史 batch topic 重叠 100% → 0% +- **Layer-A 跨批次去重**:`data/state/corpus_usage_{platform}.json` 记录 `(l2_key, topic)` 累计使用次数;新批次优先选 least-used,与历史 batch topic 重叠 100% → 0%。当某 L2 topic 池全部耗尽时输出 `[Layer-A WARN]` 提示(此时可运行 `grow-corpus.js` 扩池) - **Layer-B Opener hash**:`query_id` 决定性哈希到 5 桶之一(`Build a` / `Need a` / `Create a` / `Make a` / 无 formal opener),破除模型在 "Build a..." 上的收敛 -- **Layer-C Persona-tone 语义映射**:`scripts/corpus_persona_map.json` 按 L2 语义匹配 5 种普通用户 persona(`maker` / `planner` / `curator` / `operator` / `founder_like`),prompt 注入 voice 描述 + dev jargon 黑名单,把含 dev 术语的 query 占比从 20%(v4)压到 0.5%(v5) +- **Layer-C Persona-tone 语义映射**:`scripts/corpus_persona_map[_web].json` 按 L2 语义匹配 5 种普通用户 persona(`maker` / `planner` / `curator` / `operator` / `founder_like`),prompt 注入 voice 描述 + dev jargon 黑名单,把含 dev 术语的 query 占比从 20%(v4)压到 0.5%(v5) **复用关系:** 共享 `scripts/lib/claude-cli.js` 与 `test-corpus-methods.js`,两脚本对 claude CLI 调用统一一处实现。 +## No-API 模式:`--prep-only` + subagent + merge + +> 当 Packy API 额度耗尽或需要零 token 成本生产时,使用此三步流程。 + +**流程:** + +``` +Step 1 run-corpus.js --prep-only + → 生成 plan.jsonl + 占位 queries.jsonl(全 error=PREP_ONLY_PENDING) + → 写 /_subagent_in/{platform}_b01_in.json … _bNN_in.json(每批 25 条) + → 持久化 Layer-A usage state(与正常模式一致) + +Step 2 Claude Code subagent × N(并行) + → 每个 subagent 读取一个 *_in.json(25 条 prompt) + → 对每条 prompt 调用 claude -p --bare 生成 query_text + → 输出对应的 *_out.json([{ id, query_text }, ...]) + +Step 3 node scripts/merge-subagent-retry.js --in-dir /_subagent_in --target-dir + → 将 *_out.json 的 query_text 回写到 queries.jsonl + → 重建 queries.xlsx + → 自动更新 Layer-A usage state(从 summary.json 自动识别平台) +``` + +**Step 1 示例(500 条移动端):** + +```bash +node scripts/run-corpus.js \ + --platform mobile --total 500 \ + --prep-only --prep-batch 25 \ + --out data/output/corpus_run_mobile_500 +# → 写出 20 个 batch(mobile_b01_in.json … mobile_b20_in.json) +# → Layer-A state 已更新 +``` + +**Step 2 示例(在 Claude Code 内,每批开一个 subagent):** + +``` +对每个 _subagent_in/mobile_b01_in.json,创建一个 subagent: + 读取 mobile_b01_in.json(JSON 数组,含 25 个 { id, prompt }) + 对每条 item 用 callClaudeCli(item.prompt) 生成 item.query_text + 写出 mobile_b01_out.json([{ id, query_text }, ...]) +``` + +**Step 3 示例:** + +```bash +node scripts/merge-subagent-retry.js \ + --in-dir data/output/corpus_run_mobile_500/_subagent_in \ + --target-dir data/output/corpus_run_mobile_500 +# → queries.jsonl + queries.xlsx 更新 +# → corpus_usage_mobile.json 自动增量更新 +``` + +## 语料池扩展:`grow-corpus.js` + +> 当某个 L2 的 topic 池全部用完(Layer-A 触发 WARN)时,运行此脚本扩充语料。 + +**工作原理:** + +1. 扫描 `corpus_data[_web].json`,找出剩余新鲜 topic 数 < `--threshold` 的 L2 +2. 对每个目标 L2,调用 Claude 生成 `--expand-by` 个新 topic 候选 +3. Jaccard 相似度去重(阈值 0.4),过滤过于相似的候选 +4. 追加到 `corpus_data[_web].json`(in-place) + +```bash +# 查看哪些 L2 需要扩充(不写文件) +node scripts/grow-corpus.js --platform mobile --dry-run + +# 扩充移动端所有耗尽 L2(每个 +20 topic) +node scripts/grow-corpus.js --platform mobile + +# 指定单个 L2 +node scripts/grow-corpus.js --platform mobile --only "① 个人生活类" --expand-by 30 + +# web 端 +node scripts/grow-corpus.js --platform web +``` + +**参数:** + +| 参数 | 默认 | 说明 | +|---|---|---| +| `--platform` | `mobile` | 目标语料文件(`mobile` = `corpus_data.json`;`web` = `corpus_data_web.json`) | +| `--threshold` | `10` | 剩余新鲜 topic < N 时触发扩充;`0` = 仅扩充完全耗尽的 L2 | +| `--expand-by` | `20` | 每个 L2 目标新增 topic 数 | +| `--only` | 无 | 只扩充指定 L2 key(精确匹配) | +| `--dry-run` | 关 | 只打印计划,不调 Claude,不写文件 | +| `--no-usage-track` | 关 | 忽略 usage state,仅按 pool 大小判断 | + +> **注意**:需要 Claude API 额度(通过 `lib/claude-cli.js` 调用)。Packy 额度耗尽时用 `--dry-run` 确认目标 L2,补充额度后再运行。 + +## 历史 batch 补充:`merge-subagent-retry.js` + +> 将 subagent 生成的 `*_out.json` 结果合并回已有 `queries.jsonl`,替换 error 行。 + +**用途:** + +- No-API 流程的 Step 3(见上方) +- 手动补全某次生产跑的失败条目 + +```bash +# No-API 模式(单 run dir) +node scripts/merge-subagent-retry.js \ + --in-dir data/output/corpus_run_mobile_500/_subagent_in \ + --target-dir data/output/corpus_run_mobile_500 + +# 跳过 usage state 更新(试跑) +node scripts/merge-subagent-retry.js \ + --in-dir --target-dir --no-usage-track +``` + +| 参数 | 说明 | +|---|---| +| `--in-dir` | subagent 输出目录(含 `*_out.json`);默认 `data/output/_retry_subagent` | +| `--target-dir` | 目标 run 目录(含 `queries.jsonl`);默认查找 v7 批次目录 | +| `--usage-state` | 显式指定 usage state 路径;不传则从 `summary.json` 自动识别 `platform` | +| `--no-usage-track` | 跳过 Layer-A usage state 更新 | + ## 自由生成 plan:`build-free200-plan.js` > 为 `run:free` 提供输入 plan,也可单独调用。 diff --git a/scripts/grow-corpus.js b/scripts/grow-corpus.js new file mode 100644 index 0000000..da89e2e --- /dev/null +++ b/scripts/grow-corpus.js @@ -0,0 +1,192 @@ +/** + * grow-corpus.js + * + * Expands the topic pool for low-coverage L2 categories by generating new UI app + * topic ideas via Claude CLI. New topics are deduplicated (Jaccard similarity) + * against the existing pool before being appended to corpus_data[_web].json. + * + * Usage: + * node scripts/grow-corpus.js [options] + * + * Options: + * --platform mobile|web corpus file to expand (default: mobile) + * --threshold expand L2s with fewer than n fresh (unused) topics remaining (default: 10; 0 = only expand fully exhausted) + * --expand-by target new topics per L2 (default: 20) + * --only only expand this specific L2 key (exact match) + * --dry-run print plan without calling Claude or writing files + * --no-usage-track ignore usage state; rely solely on pool-size threshold + * --model override Claude model + */ + +const fs = require("fs"); +const path = require("path"); +const { applyPackyEnv, callClaudeCli, CLI_MODEL } = require("./lib/claude-cli"); + +applyPackyEnv(path.resolve(__dirname, "..")); + +// ── CLI args ──────────────────────────────────────────────────────────────── +function arg(name) { + const i = process.argv.indexOf("--" + name); + return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : null; +} +function flag(name) { return process.argv.includes("--" + name); } + +const PLATFORM = arg("platform") || "mobile"; +const THRESHOLD = Number(arg("threshold") ?? 10); +const EXPAND_BY = Number(arg("expand-by") ?? 20); +const ONLY_KEY = arg("only") || null; +const DRY_RUN = flag("dry-run"); +const NO_USAGE_TRACK = flag("no-usage-track"); +const MODEL = arg("model") || CLI_MODEL; + +const ROOT_DIR = path.resolve(__dirname, ".."); +const CORPUS_FILE = PLATFORM === "web" + ? path.join(__dirname, "corpus_data_web.json") + : path.join(__dirname, "corpus_data.json"); +const USAGE_FILE = path.join(ROOT_DIR, "data", "state", `corpus_usage_${PLATFORM}.json`); + +// ── Similarity deduplication ───────────────────────────────────────────────── +function tokenize(text) { + return new Set(String(text).toLowerCase().replace(/[^\w\s]/g, " ").trim().split(/\s+/).filter(Boolean)); +} + +function jaccard(setA, setB) { + const intersection = [...setA].filter((x) => setB.has(x)).length; + const union = new Set([...setA, ...setB]).size; + return union === 0 ? 0 : intersection / union; +} + +function tooSimilar(newTopic, existingTopics, threshold = 0.4) { + const newTokens = tokenize(newTopic); + return existingTopics.some((t) => jaccard(newTokens, tokenize(t)) > threshold); +} + +// ── Prompt builder ──────────────────────────────────────────────────────────── +function buildExpansionPrompt(l2full, existingTopics, count) { + return `You are helping expand a UI/UX query dataset. Your task is to generate ${count} new, creative, and distinct UI app topic ideas for the following category: + +Category: ${l2full} + +Existing topics (do NOT repeat or closely paraphrase any of these): +${existingTopics.map((t, i) => `${i + 1}. ${t}`).join("\n")} + +Requirements: +- Each new topic is a 3-7 word English noun phrase (like the existing ones) +- Topics should be concrete, specific UI application ideas that fit the category +- Avoid repeating concepts already covered by the existing list +- Vary the topic types: different user roles, use cases, industries, and interaction patterns +- No numbering, no bullets, no explanations — just one topic per line + +Output exactly ${count} new topic phrases, one per line:`; +} + +// ── Parse Claude output ─────────────────────────────────────────────────────── +function parseTopics(rawOutput) { + return rawOutput + .split(/\r?\n/) + .map((line) => line.replace(/^\d+[\.\)]\s*/, "").replace(/^[-•*]\s*/, "").trim()) + .filter((line) => line.length > 3 && line.length < 120 && /\w{2,}/.test(line)); +} + +// ── Load usage state ────────────────────────────────────────────────────────── +function loadUsage() { + if (!fs.existsSync(USAGE_FILE)) return {}; + try { + return JSON.parse(fs.readFileSync(USAGE_FILE, "utf8")).usage || {}; + } catch { + return {}; + } +} + +// ── Main ────────────────────────────────────────────────────────────────────── +async function main() { + if (!fs.existsSync(CORPUS_FILE)) { + console.error(`Corpus file not found: ${CORPUS_FILE}`); + process.exit(1); + } + + const corpus = JSON.parse(fs.readFileSync(CORPUS_FILE, "utf8")); + const usage = NO_USAGE_TRACK ? {} : loadUsage(); + const keys = ONLY_KEY ? [ONLY_KEY] : Object.keys(corpus); + + // Determine which L2s need expansion + const targets = keys + .filter((k) => corpus[k]) + .map((k) => { + const topics = corpus[k].topics || []; + const usedKeys = NO_USAGE_TRACK ? 0 : Object.keys(usage[k] || {}).length; + const poolSize = topics.length; + const freshLeft = poolSize - usedKeys; + // Expand if: fewer than THRESHOLD fresh topics remain (0 = only expand fully exhausted) + const needsExpansion = freshLeft <= THRESHOLD; + return { k, topics, poolSize, usedKeys, freshLeft, needsExpansion }; + }) + .filter(({ needsExpansion }) => needsExpansion); + + if (targets.length === 0) { + console.log(`No L2s need expansion (threshold=${THRESHOLD}, platform=${PLATFORM})`); + return; + } + + console.log(`\n[grow-corpus] platform=${PLATFORM} expand-by=${EXPAND_BY} model=${MODEL}`); + console.log(` ${targets.length} L2s targeted for expansion (threshold: fresh<${THRESHOLD}):`); + for (const { k, poolSize, usedKeys, freshLeft } of targets) { + console.log(` · ${k} (pool=${poolSize}, used=${usedKeys}, fresh=${freshLeft})`); + } + + if (DRY_RUN) { + console.log("\n[dry-run] No Claude calls or file writes."); + return; + } + + let totalAdded = 0; + + for (const { k, topics } of targets) { + const l2full = corpus[k].l2full || k; + console.log(`\n→ Expanding [${k}] (${topics.length} existing topics, target +${EXPAND_BY})`); + + let raw; + try { + raw = await callClaudeCli(buildExpansionPrompt(l2full, topics, EXPAND_BY + 5), { + model: MODEL, + label: k, + timeoutMs: 60000, + }); + } catch (e) { + console.error(` ✗ Claude call failed: ${e.message}`); + continue; + } + + const candidates = parseTopics(raw); + const accepted = []; + for (const t of candidates) { + if (accepted.length >= EXPAND_BY) break; + if (tooSimilar(t, [...topics, ...accepted])) { + console.log(` ~ skip (similar): ${t}`); + continue; + } + accepted.push(t); + console.log(` + ${t}`); + } + + if (accepted.length === 0) { + console.log(` ⚠️ no new topics accepted after dedup`); + continue; + } + + corpus[k].topics = [...topics, ...accepted]; + totalAdded += accepted.length; + console.log(` ✓ added ${accepted.length} topics → pool now ${corpus[k].topics.length}`); + } + + if (totalAdded > 0) { + fs.writeFileSync(CORPUS_FILE, JSON.stringify(corpus, null, 2) + "\n", "utf8"); + console.log(`\n📚 Saved ${CORPUS_FILE} (+${totalAdded} topics across ${targets.length} L2s)`); + } else { + console.log("\n⚠️ No new topics were added."); + } + + console.log("\n✅ Done"); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/scripts/run-corpus.js b/scripts/run-corpus.js index 007cef8..76d5087 100644 --- a/scripts/run-corpus.js +++ b/scripts/run-corpus.js @@ -26,7 +26,7 @@ * node scripts/run-corpus.js --total 200 --no-usage-track # 关 usage 跟踪(一次性试跑) * * 三层多样性机制(默认全部启用): - * Layer-A 跨批次去重:data/state/corpus_usage.json 记录 (l2_key, topic) 累计使用次数;新批次优先选 least-used + * Layer-A 跨批次去重:data/state/corpus_usage_{platform}.json 记录 (l2_key, topic) 累计使用次数;新批次优先选 least-used * Layer-B Opener hash:query_id 哈希到 5 桶之一(Build a / Need a / Create a / Make a / no formal opener) * Layer-C Persona 语义匹配:scripts/corpus_persona_map.json 按 L2 语义最佳匹配 5 种普通用户 persona,注入 voice 描述 + dev jargon 黑名单 */ @@ -95,16 +95,17 @@ async function main() { args["exclude-l1"] && args["exclude-l1"] !== true ? String(args["exclude-l1"]).split(",").map((s) => s.trim()).filter(Boolean) : []; - const usageStatePath = path.resolve( - process.cwd(), - args["usage-state"] || "data/state/corpus_usage.json", - ); const noUsageTrack = args["no-usage-track"] === true; // default: track + persist usage const platformArg = (args.platform && args.platform !== true) ? String(args.platform) : DEFAULT_CORPUS_PLATFORM; if (!CORPUS_PLATFORMS[platformArg]) { throw new Error(`未知的 --platform 值:${platformArg}。可选:${Object.keys(CORPUS_PLATFORMS).join(", ")}`); } const platform = CORPUS_PLATFORMS[platformArg]; + // Per-platform state path: corpus_usage_{platform}.json so mobile and web never collide. + const usageStatePath = path.resolve( + process.cwd(), + args["usage-state"] || `data/state/corpus_usage_${platformArg}.json`, + ); // Per-platform default file paths. CLI flags override. const isWeb = platformArg === "web";