@petercjl/taobao-ai-ops 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -22
- package/package.json +2 -7
- package/requirements.txt +1 -0
- package/skills/taobao-ai-operations/SKILL.md +8 -1
- package/src/cli.mjs +16 -6
- package/src/components.mjs +73 -0
- package/src/core.mjs +5 -1
- package/src/runtime.mjs +18 -16
- package/src/skill-manager.mjs +4 -4
- package/src/update.mjs +5 -5
- package/src/workflow.mjs +3 -3
- package/suite-manifest.json +186 -10
- package/skills/taobao-search-product-form/SKILL.md +0 -74
- package/skills/taobao-search-product-form/adapters/codex.json +0 -139
- package/skills/taobao-search-product-form/adapters/sealseek.json +0 -24
- package/skills/taobao-search-product-form/agents/openai.yaml +0 -3
- package/skills/taobao-search-product-form/capabilities.json +0 -148
- package/skills/taobao-search-product-form/references/SCHEMA.md +0 -3
- package/skills/taobao-search-product-form/references/index.md +0 -10
- package/skills/taobao-search-product-form/references/log.md +0 -35
- package/skills/taobao-search-product-form/references/queries/evaluate-white-label-forms.md +0 -3
- package/skills/taobao-search-product-form/references/queries/identify-advertisers.md +0 -5
- package/skills/taobao-search-product-form/references/queries/identify-geography.md +0 -5
- package/skills/taobao-search-product-form/references/queries/identify-price-bands.md +0 -3
- package/skills/taobao-search-product-form/references/queries/identify-product-forms.md +0 -3
- package/skills/taobao-search-product-form/references/queries/identify-title-roots.md +0 -5
- package/skills/taobao-search-product-form/references/report-output.md +0 -23
- package/skills/taobao-search-product-form/references/source-manifest.md +0 -37
- package/skills/taobao-search-product-form/references/title-lexicon.json +0 -55
- package/skills/taobao-search-product-form/references/topics/advertiser-analysis-method.md +0 -43
- package/skills/taobao-search-product-form/references/topics/geography-analysis-method.md +0 -23
- package/skills/taobao-search-product-form/references/topics/price-band-analysis-method.md +0 -27
- package/skills/taobao-search-product-form/references/topics/title-root-analysis-method.md +0 -37
- package/skills/taobao-search-product-form/references/topics/visual-form-method.md +0 -31
- package/skills/taobao-search-product-form/references/topics/white-label-evidence.md +0 -38
- package/skills/taobao-search-product-form/references/workflow-ledger.md +0 -28
- package/skills/taobao-search-product-form/report_runtime/VERSION +0 -1
- package/skills/taobao-search-product-form/report_runtime/renderer.py +0 -485
- package/skills/taobao-search-product-form/report_runtime/templates/workbench.html +0 -67
- package/skills/taobao-search-product-form/report_runtime/validator.py +0 -117
- package/skills/taobao-search-product-form/scripts/analyze.py +0 -119
- package/skills/taobao-search-product-form/scripts/analyze_advertisers.py +0 -352
- package/skills/taobao-search-product-form/scripts/analyze_geography.py +0 -316
- package/skills/taobao-search-product-form/scripts/analyze_price_bands.py +0 -257
- package/skills/taobao-search-product-form/scripts/analyze_title_roots.py +0 -327
- package/skills/taobao-search-product-form/scripts/assemble_opportunity_evidence.py +0 -322
- package/skills/taobao-search-product-form/scripts/build_report_viewmodel.py +0 -822
- package/skills/taobao-search-product-form/scripts/build_visual_review_viewmodel.py +0 -177
- package/skills/taobao-search-product-form/scripts/clean_search_export.mjs +0 -61
- package/skills/taobao-search-product-form/scripts/cluster_visual_forms.py +0 -193
- package/skills/taobao-search-product-form/scripts/compile_style_prototypes.py +0 -115
- package/skills/taobao-search-product-form/scripts/compile_visual_review.py +0 -60
- package/skills/taobao-search-product-form/scripts/compile_visual_selection.py +0 -148
- package/skills/taobao-search-product-form/scripts/prepare.py +0 -180
- package/skills/taobao-search-product-form/scripts/render_report.py +0 -31
- package/skills/taobao-search-product-form/scripts/validate_opportunity_cards.py +0 -84
- package/skills/taobao-search-product-form/scripts/validate_report_viewmodel.py +0 -152
- package/skills/taobao-search-product-form/scripts/visual_form_pipeline.py +0 -247
- package/skills/taobao-search-product-form/scripts/visual_observation_batches.py +0 -117
- package/skills/taobao-search-product-form/scripts/workflow_ledger.py +0 -177
- package/skills/yuce-category-opportunity-report/SKILL.md +0 -151
- package/skills/yuce-category-opportunity-report/adapters/codex.json +0 -113
- package/skills/yuce-category-opportunity-report/adapters/sealseek.json +0 -39
- package/skills/yuce-category-opportunity-report/agents/openai.yaml +0 -3
- package/skills/yuce-category-opportunity-report/assets/chart-views.js +0 -80
- package/skills/yuce-category-opportunity-report/assets/package.json +0 -1
- package/skills/yuce-category-opportunity-report/assets/report-template.html +0 -62
- package/skills/yuce-category-opportunity-report/capabilities.json +0 -48
- package/skills/yuce-category-opportunity-report/references/SCHEMA.md +0 -5
- package/skills/yuce-category-opportunity-report/references/data-contract.md +0 -40
- package/skills/yuce-category-opportunity-report/references/index.md +0 -6
- package/skills/yuce-category-opportunity-report/references/log.md +0 -29
- package/skills/yuce-category-opportunity-report/references/queries/assess-category-opportunity.md +0 -7
- package/skills/yuce-category-opportunity-report/references/source-manifest.md +0 -14
- package/skills/yuce-category-opportunity-report/references/topics/category-opportunity-method.md +0 -39
- package/skills/yuce-category-opportunity-report/scripts/build_report.py +0 -779
- package/skills/yuce-category-opportunity-report/scripts/market_routes.py +0 -139
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Map three-axis visual evidence to the pinned workbench contract.
|
|
3
|
-
|
|
4
|
-
This script writes a ViewModel only; the bundled runtime owns HTML rendering and QA.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import argparse
|
|
8
|
-
import hashlib
|
|
9
|
-
import json
|
|
10
|
-
from collections import Counter, defaultdict
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def load(path):
|
|
15
|
-
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def digest(path):
|
|
19
|
-
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def title_count(label, count):
|
|
23
|
-
return f"{label} · {count}件"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def build(args):
|
|
27
|
-
detail = load(args.labels)
|
|
28
|
-
summary = load(args.summary)
|
|
29
|
-
plan = load(args.plan)
|
|
30
|
-
if len(detail["records"]) != summary["reviewed_products"]:
|
|
31
|
-
raise ValueError("detail and summary product counts disagree")
|
|
32
|
-
products = detail["records"]
|
|
33
|
-
subject = args.subject.strip() or "搜索商品"
|
|
34
|
-
qa_note = args.qa_note.strip()
|
|
35
|
-
uncertain_count = sum(p["status"] == "uncertain" for p in products)
|
|
36
|
-
product_lookup = {p["product_id"]: p for p in products}
|
|
37
|
-
manifest = load(plan["source_manifest"])
|
|
38
|
-
source_lookup = {str(p["product_id"]): p for p in manifest["products"]}
|
|
39
|
-
assets = {}
|
|
40
|
-
for p in products:
|
|
41
|
-
assets[f"p{p['product_id']}"] = {"path": p["image_path"], "alt": f"商品 {p['product_id']} 主图",
|
|
42
|
-
"role": "source-product-image", "subject_id": p["product_id"],
|
|
43
|
-
"evidence_level": "direct"}
|
|
44
|
-
|
|
45
|
-
def product_card(p):
|
|
46
|
-
form = p["form"]["label"] or "款式待确认"
|
|
47
|
-
style = p["style"]["label"] or "风格未归类"
|
|
48
|
-
audience = p["audience"]["label"] or "人群证据不足"
|
|
49
|
-
source = source_lookup[p["product_id"]]
|
|
50
|
-
evidence = p["visible_evidence"]
|
|
51
|
-
if p["style"].get("physical_delta"):
|
|
52
|
-
evidence += "|风格落点:" + p["style"]["physical_delta"]
|
|
53
|
-
if p["audience"].get("physical_delta"):
|
|
54
|
-
evidence += "|任务适配:" + p["audience"]["physical_delta"]
|
|
55
|
-
return {"asset_id": f"p{p['product_id']}", "title": f"N{p['source_rank']} · {p['product_id']}",
|
|
56
|
-
"price": f"¥{source.get('price', 0):.2f}" if source.get("price") is not None else "价格未给出",
|
|
57
|
-
"badge": "边界待确认" if p["status"] == "uncertain" else "图片试打标",
|
|
58
|
-
"tone": "warning" if p["status"] == "uncertain" else "info",
|
|
59
|
-
"facts": [{"label": "款式", "value": form}, {"label": "风格", "value": style},
|
|
60
|
-
{"label": "人群/任务", "value": audience}],
|
|
61
|
-
"evidence": evidence, "copy_value": p["product_id"], "copy_label": "复制商品ID"}
|
|
62
|
-
|
|
63
|
-
def sample_cards(axis, group, maximum=3):
|
|
64
|
-
return [product_card(p) for p in products if p[axis]["label"] == group][:maximum]
|
|
65
|
-
|
|
66
|
-
def group_view(axis, label, title, subtitle):
|
|
67
|
-
counts = summary["axes"][axis]["canonical_counts"]
|
|
68
|
-
rows = [[name, str(count), "、".join(summary["axes"][axis]["examples"].get(name, []))]
|
|
69
|
-
for name, count in sorted(counts.items(), key=lambda item: (-item[1], item[0]))]
|
|
70
|
-
tabs = [{"id": f"{axis}-{i+1}", "label": name, "badge": str(count),
|
|
71
|
-
"blocks": [{"type": "products", "columns": 3, "items": sample_cards(axis, name)}]}
|
|
72
|
-
for i, (name, count) in enumerate(sorted(counts.items(), key=lambda item: (-item[1], item[0])))]
|
|
73
|
-
return {"id": axis, "label": label, "eyebrow": "三维打标", "title": title, "subtitle": subtitle,
|
|
74
|
-
"blocks": [{"type": "alert", "tone": "info",
|
|
75
|
-
"text": f"本维度已归类 {sum(counts.values())} 件,未归类 {summary['axes'][axis]['unclassified_products']} 件;下方为各组代表图片。"},
|
|
76
|
-
{"type": "table", "title": "标签分布", "columns": ["标签", "件数", "代表商品ID"], "rows": rows},
|
|
77
|
-
{"type": "tabs", "title": "逐类看图", "tabs": tabs}]}
|
|
78
|
-
|
|
79
|
-
audience_counts = summary["axes"]["audience"]["canonical_counts"]
|
|
80
|
-
proposal_rows = []
|
|
81
|
-
for name, count in sorted(audience_counts.items(), key=lambda item: (-item[1], item[0])):
|
|
82
|
-
members = [p for p in products if p["audience"]["label"] == name]
|
|
83
|
-
forms = Counter(p["form"]["label"] or "边界待定" for p in members)
|
|
84
|
-
deltas = list(dict.fromkeys(p["audience"].get("physical_delta", "") for p in members))
|
|
85
|
-
proposal_rows.append([name, str(count), "、".join(f"{form} {n}" for form, n in forms.most_common()),
|
|
86
|
-
";".join(x for x in deltas[:2] if x)])
|
|
87
|
-
many_styles = []
|
|
88
|
-
by_form = defaultdict(Counter)
|
|
89
|
-
for p in products:
|
|
90
|
-
if p["form"]["label"] and p["style"]["label"]:
|
|
91
|
-
by_form[p["form"]["label"]][p["style"]["label"]] += 1
|
|
92
|
-
for form, styles in by_form.items():
|
|
93
|
-
if len(styles) >= 2:
|
|
94
|
-
many_styles.append([form, str(sum(styles.values())),
|
|
95
|
-
"、".join(f"{style} {count}" for style, count in styles.most_common())])
|
|
96
|
-
|
|
97
|
-
model = {
|
|
98
|
-
"contract": "compact-workbench@1.0",
|
|
99
|
-
"meta": {"title": f"{subject}三维打标|{len(products)}品审核版",
|
|
100
|
-
"subtitle": f"款式 × 产品风格 × 任务型人群;自然位{len(products)}个商品的开发模式样例",
|
|
101
|
-
"footer": "图片来自已提供的淘宝搜索导出;标签为待审核试打标,不代表全市场或实际购买人群。",
|
|
102
|
-
"source_payload_sha256": digest(args.labels)},
|
|
103
|
-
"shell": {"brand_mark": "选", "brand_title": "商品形态审核", "brand_subtitle": "三维标签与可选货方案",
|
|
104
|
-
"nav_label": "审核路径", "search": True, "search_placeholder": "搜索当前页面的商品ID或标签",
|
|
105
|
-
"status": ["开发模式 · 待审核", f"{len(products)}件自然位商品", "结构/风格/任务三维"],
|
|
106
|
-
"sidebar_tabs": True,
|
|
107
|
-
"reading_path": [{"view_id": "overview", "label": "先看试打标边界"},
|
|
108
|
-
{"view_id": "form", "label": "再看结构款式"},
|
|
109
|
-
{"view_id": "style", "label": "比较产品风格"},
|
|
110
|
-
{"view_id": "audience", "label": "核对人群任务"},
|
|
111
|
-
{"view_id": "propositions", "label": "审视候选商品方案"},
|
|
112
|
-
{"view_id": "all", "label": "逐品核对"}]},
|
|
113
|
-
"assets": assets,
|
|
114
|
-
"views": [
|
|
115
|
-
{"id": "overview", "label": "结论与范围", "eyebrow": "待审核试跑", "title": "三维标签如何服务选款",
|
|
116
|
-
"subtitle": "当前只验证打标和聚合逻辑,不宣布市场机会。",
|
|
117
|
-
"blocks": [
|
|
118
|
-
{"type": "conclusion", "title": "先看三项判断", "items": [
|
|
119
|
-
{"label": "款式", "tone": "info", "segments": [{"text": "结构款式", "emphasis": "strong"}, {"text": "是基础分类;颜色不会单独拆出一个锅型。"}]},
|
|
120
|
-
{"label": "风格", "tone": "positive", "segments": [{"text": "同一结构款可以形成不同的产品风格方案", "emphasis": "strong", "tone": "positive"}],
|
|
121
|
-
"note": "风格必须对应锅体、手柄、表面等可见产品设计差异。"},
|
|
122
|
-
{"label": "人群", "tone": "warning", "segments": [{"text": "目标人群是使用任务假设", "emphasis": "strong"},
|
|
123
|
-
{"text": ",不是已验证的真实买家。", "emphasis": "italic", "tone": "warning"}],
|
|
124
|
-
"note": "证据不足的商品保留空标签。"}]},
|
|
125
|
-
{"type": "metrics", "columns": 4, "items": [
|
|
126
|
-
{"label": "商品图片", "value": str(len(products)), "note": f"占清洗自然位 {len(products)}/{plan['scope']['manifest_natural_total']}"},
|
|
127
|
-
{"label": "结构款式", "value": str(len(summary["axes"]["form"]["canonical_counts"])), "note": f"{uncertain_count}件目标边界待确认"},
|
|
128
|
-
{"label": "风格已归类", "value": str(sum(summary["axes"]["style"]["canonical_counts"].values())), "note": "其余保留未归类"},
|
|
129
|
-
{"label": "任务人群候选", "value": str(sum(audience_counts.values())), "note": "图片线索支持,仍需市场验证"}]},
|
|
130
|
-
{"type": "alert", "title": "样本口径", "tone": "warning",
|
|
131
|
-
"text": f"只选已清洗自然位的前{len(products)}个商品,并非随机抽样。品牌和排序可能造成偏斜;本页件数不是搜索市场份额。"},
|
|
132
|
-
{"type": "list", "title": "读这份报告的顺序", "ordered": True,
|
|
133
|
-
"items": ["先看每个维度的标签与代表图片,确认边界是否符合选款认知。",
|
|
134
|
-
"再看同一结构款的风格差异,以及针对任务的产品结构变化。",
|
|
135
|
-
"最后在逐品页核对全部50张图;有争议的标签回到原始观察,不直接修改聚合结果。"]}
|
|
136
|
-
]},
|
|
137
|
-
group_view("form", "款式", "结构款式分类", f"主款式每商品最多一个;{uncertain_count}件目标范围待确认。"),
|
|
138
|
-
group_view("style", "风格", "产品设计风格", "仅用产品本身的形状、色彩、表面和部件;促销画面风格不入类。"),
|
|
139
|
-
group_view("audience", "人群", "目标任务人群候选", "标签指向使用任务及具体产品适配,不推断实际买家人口属性。"),
|
|
140
|
-
{"id": "propositions", "label": "候选商品方案", "eyebrow": "三维交叉", "title": "标签怎样变成可做的货",
|
|
141
|
-
"subtitle": "这些只是待研究的商品方案,不是投资、采购或入场建议。",
|
|
142
|
-
"blocks": [
|
|
143
|
-
{"type": "alert", "tone": "info", "text": "下表只列样本中实际出现、且标注了产品侧变化的任务方向;稀少不等于有需求空缺。"},
|
|
144
|
-
{"type": "table", "title": "任务驱动的候选方案", "columns": ["任务人群", "样本件数", "基础款式", "实际产品变化"], "rows": proposal_rows},
|
|
145
|
-
{"type": "table", "title": "同款式的风格分化", "columns": ["基础款式", "有风格标签的件数", "可见风格"], "rows": many_styles},
|
|
146
|
-
{"type": "callout", "text": "下一步需按商品ID联接价格、付款人数、店铺及广告位,再判断哪些组合有真实市场信号;图片本身不能证明白牌机会。"}]},
|
|
147
|
-
{"id": "all", "label": f"{len(products)}品逐项审核", "eyebrow": "完整证据", "title": "逐商品核对图片与标签",
|
|
148
|
-
"subtitle": "按自然位源排序;每张卡同时显示款式、风格、人群任务与可见依据。",
|
|
149
|
-
"blocks": [{"type": "alert", "tone": "warning", "text": qa_note or "识图次数、复核例外与并发能力以本次运行台账为准。"},
|
|
150
|
-
{"type": "products", "columns": 3, "items": [product_card(p) for p in products]}]},
|
|
151
|
-
{"id": "method", "label": "口径与限制", "eyebrow": "方法", "title": "什么被标注,什么仍未知",
|
|
152
|
-
"subtitle": "三维可并行选款,证据等级不同。",
|
|
153
|
-
"blocks": [{"type": "list", "ordered": True,
|
|
154
|
-
"items": ["款式是可见几何与结构的分类;主款式每个目标商品最多一个。",
|
|
155
|
-
"风格由至少两项产品本体线索支持,并写明可改变的产品设计部位。",
|
|
156
|
-
"人群是使用任务假设;必须有任务线索和可落到商品上的适配点。",
|
|
157
|
-
f"{uncertain_count}件目标边界待确认,等待上游目标范围决定是否纳入。",
|
|
158
|
-
"图片字样属于商家主张,不等于性能已验证;付款人数亦有不明时间窗口。",
|
|
159
|
-
"语义识图的实际并发量与重复阅读情况应按本次运行记录披露;不由图片下载并发推断。"]}]}
|
|
160
|
-
]}
|
|
161
|
-
output = Path(args.output).resolve()
|
|
162
|
-
with output.open("x", encoding="utf-8") as stream:
|
|
163
|
-
json.dump(model, stream, ensure_ascii=False, indent=2)
|
|
164
|
-
stream.write("\n")
|
|
165
|
-
print(json.dumps({"viewmodel": str(output), "views": len(model["views"]),
|
|
166
|
-
"assets": len(assets), "products": len(products)}, ensure_ascii=False))
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if __name__ == "__main__":
|
|
170
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
171
|
-
parser.add_argument("--plan", required=True)
|
|
172
|
-
parser.add_argument("--labels", required=True)
|
|
173
|
-
parser.add_argument("--summary", required=True)
|
|
174
|
-
parser.add_argument("--output", required=True)
|
|
175
|
-
parser.add_argument("--subject", default="搜索商品")
|
|
176
|
-
parser.add_argument("--qa-note", default="")
|
|
177
|
-
build(parser.parse_args())
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/** Split an XLSX search export into first-seen natural products and all advertising appearances. */
|
|
3
|
-
import fs from 'node:fs/promises';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import ExcelJS from '@excel.js/exceljs';
|
|
6
|
-
|
|
7
|
-
const [sourcePath, outputPath] = process.argv.slice(2);
|
|
8
|
-
if (!sourcePath || !outputPath) throw new Error('Usage: node clean_search_export.mjs SOURCE.xlsx OUTPUT.xlsx');
|
|
9
|
-
if (!(await fs.stat(sourcePath)).isFile()) throw new Error('Source is not a file');
|
|
10
|
-
if (await fs.stat(outputPath).then(() => true, e => { if (e.code === 'ENOENT') return false; throw e; })) {
|
|
11
|
-
throw new Error(`Output already exists: ${outputPath}`);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const source = new ExcelJS.Workbook();
|
|
15
|
-
await source.xlsx.readFile(sourcePath);
|
|
16
|
-
const input = source.worksheets[0];
|
|
17
|
-
if (!input) throw new Error('Source workbook has no worksheet');
|
|
18
|
-
const headers = input.getRow(1).values.slice(1).map(value => String(value ?? '').trim());
|
|
19
|
-
const idColumn = headers.indexOf('商品ID');
|
|
20
|
-
const placementColumn = headers.indexOf('占位类型');
|
|
21
|
-
if (idColumn < 0 || placementColumn < 0) throw new Error('Source requires 商品ID and 占位类型 columns');
|
|
22
|
-
|
|
23
|
-
const natural = [], ads = [], seenNatural = new Set();
|
|
24
|
-
let duplicateNatural = 0;
|
|
25
|
-
for (let rowIndex = 2; rowIndex <= input.rowCount; rowIndex++) {
|
|
26
|
-
const row = input.getRow(rowIndex);
|
|
27
|
-
const values = headers.map((_, columnIndex) => row.getCell(columnIndex + 1).value);
|
|
28
|
-
if (values.every(value => value === null || value === undefined || value === '')) continue;
|
|
29
|
-
const id = String(values[idColumn] ?? '').trim();
|
|
30
|
-
const placement = String(values[placementColumn] ?? '').trim();
|
|
31
|
-
if (!id) throw new Error(`Blank 商品ID at source row ${rowIndex}`);
|
|
32
|
-
values[idColumn] = id;
|
|
33
|
-
if (placement === '自然位') {
|
|
34
|
-
if (seenNatural.has(id)) { duplicateNatural++; continue; }
|
|
35
|
-
seenNatural.add(id);
|
|
36
|
-
natural.push(values);
|
|
37
|
-
} else if (placement === '广告位') ads.push(values);
|
|
38
|
-
else throw new Error(`Unknown 占位类型 at source row ${rowIndex}: ${placement}`);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const output = new ExcelJS.Workbook();
|
|
42
|
-
for (const [name, data] of [['自然位', natural], ['广告位', ads]]) {
|
|
43
|
-
const sheet = output.addWorksheet(name, { views: [{ state: 'frozen', ySplit: 1 }] });
|
|
44
|
-
sheet.addRow(headers);
|
|
45
|
-
for (const values of data) sheet.addRow(values);
|
|
46
|
-
sheet.getColumn(idColumn + 1).numFmt = '@';
|
|
47
|
-
sheet.getRow(1).font = { name: 'Arial', size: 10, bold: true, color: { argb: 'FFFFFFFF' } };
|
|
48
|
-
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1F3A5F' } };
|
|
49
|
-
sheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: data.length + 1, column: headers.length } };
|
|
50
|
-
sheet.columns.forEach((column, index) => { column.width = index === 2 ? 48 : 20; });
|
|
51
|
-
}
|
|
52
|
-
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
53
|
-
await output.xlsx.writeFile(outputPath);
|
|
54
|
-
console.log(JSON.stringify({
|
|
55
|
-
sourceRows: natural.length + ads.length + duplicateNatural,
|
|
56
|
-
naturalSourceRows: natural.length + duplicateNatural,
|
|
57
|
-
naturalRetainedRows: natural.length,
|
|
58
|
-
naturalDuplicateRowsRemoved: duplicateNatural,
|
|
59
|
-
adRowsRetained: ads.length,
|
|
60
|
-
output: outputPath,
|
|
61
|
-
}, null, 2));
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Prepare and apply an auditable, category-neutral visual-form hierarchy.
|
|
3
|
-
|
|
4
|
-
Semantic family decisions are made by the host Agent from class profiles and
|
|
5
|
-
representative image observations. This script validates and applies them; it
|
|
6
|
-
never derives form identity from title, price, payments, or a preset class list.
|
|
7
|
-
Outputs must be new paths so raw image observations remain immutable.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import argparse
|
|
13
|
-
import json
|
|
14
|
-
import re
|
|
15
|
-
from collections import Counter, defaultdict
|
|
16
|
-
from pathlib import Path
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def load(path):
|
|
20
|
-
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def save_new(path, value):
|
|
24
|
-
target = Path(path)
|
|
25
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
-
with target.open("x", encoding="utf-8") as stream:
|
|
27
|
-
json.dump(value, stream, ensure_ascii=False, indent=2)
|
|
28
|
-
stream.write("\n")
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def grams(value):
|
|
32
|
-
text = re.sub(r"\s+", "", value.casefold())
|
|
33
|
-
if len(text) < 2:
|
|
34
|
-
return {text}
|
|
35
|
-
return {text[index:index + 2] for index in range(len(text) - 1)}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def similarity(left, right):
|
|
39
|
-
a, b = grams(left), grams(right)
|
|
40
|
-
return round(len(a & b) / len(a | b), 3) if a | b else 0.0
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def prepare(labels_path, summary_path, output_path):
|
|
44
|
-
labels, summary = load(labels_path), load(summary_path)
|
|
45
|
-
records = labels["records"]
|
|
46
|
-
counts = summary["axes"]["form"]["canonical_counts"]
|
|
47
|
-
by_label = defaultdict(list)
|
|
48
|
-
for row in records:
|
|
49
|
-
name = row["form"].get("label")
|
|
50
|
-
if name:
|
|
51
|
-
by_label[name].append(row)
|
|
52
|
-
if {name: len(rows) for name, rows in by_label.items()} != counts:
|
|
53
|
-
raise ValueError("form summary and product labels disagree")
|
|
54
|
-
profiles = []
|
|
55
|
-
for name, rows in sorted(by_label.items(), key=lambda item: (-len(item[1]), item[0])):
|
|
56
|
-
examples = sorted(rows, key=lambda row: row["source_rank"])[:3]
|
|
57
|
-
profiles.append({
|
|
58
|
-
"source_label": name,
|
|
59
|
-
"products": len(rows),
|
|
60
|
-
"candidate_words": dict(Counter(row["form"].get("candidate") for row in rows).most_common(8)),
|
|
61
|
-
"style_counts": dict(Counter(row["style"].get("label") for row in rows if row["style"].get("label")).most_common(5)),
|
|
62
|
-
"task_counts": dict(Counter(row["audience"].get("label") for row in rows if row["audience"].get("label")).most_common(5)),
|
|
63
|
-
"examples": [{"product_id": row["product_id"], "visible_evidence": row["visible_evidence"],
|
|
64
|
-
"image_path": row["image_path"]} for row in examples],
|
|
65
|
-
})
|
|
66
|
-
neighbors = []
|
|
67
|
-
for index, first in enumerate(profiles):
|
|
68
|
-
options = sorted(({"source_label": other["source_label"],
|
|
69
|
-
"name_similarity": similarity(first["source_label"], other["source_label"])}
|
|
70
|
-
for j, other in enumerate(profiles) if j != index),
|
|
71
|
-
key=lambda row: (-row["name_similarity"], row["source_label"]))
|
|
72
|
-
neighbors.append({"source_label": first["source_label"], "possible_neighbors": options[:5]})
|
|
73
|
-
save_new(output_path, {"schema_version": 1, "purpose": "semantic form-family adjudication input",
|
|
74
|
-
"source_registry_version": summary.get("registry_version"),
|
|
75
|
-
"product_count": sum(counts.values()), "profiles": profiles,
|
|
76
|
-
"name_neighbors_for_recall_only": neighbors,
|
|
77
|
-
"boundary": "Name similarity proposes comparisons; image-grounded structure decides grouping."})
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def apply(labels_path, summary_path, decisions_path, output_dir):
|
|
81
|
-
labels, summary, decisions = load(labels_path), load(summary_path), load(decisions_path)
|
|
82
|
-
if decisions.get("schema_version") != 1:
|
|
83
|
-
raise ValueError("cluster decision schema_version must be 1")
|
|
84
|
-
original_counts = summary["axes"]["form"]["canonical_counts"]
|
|
85
|
-
rows = decisions.get("mappings", [])
|
|
86
|
-
if not rows and decisions.get("families"):
|
|
87
|
-
for family in decisions["families"]:
|
|
88
|
-
for variant in family.get("variants", []):
|
|
89
|
-
for source in variant.get("sources", []):
|
|
90
|
-
rows.append({"source_label": source, "family": family["family"],
|
|
91
|
-
"variant": variant["variant"],
|
|
92
|
-
"features": variant.get("features", []),
|
|
93
|
-
"reason": variant.get("reason") or family.get("reason"),
|
|
94
|
-
"confidence": variant.get("confidence", family.get("confidence", "high"))})
|
|
95
|
-
owners = {}
|
|
96
|
-
for row in rows:
|
|
97
|
-
source = str(row.get("source_label") or "").strip()
|
|
98
|
-
family = str(row.get("family") or "").strip()
|
|
99
|
-
variant = str(row.get("variant") or "").strip()
|
|
100
|
-
reason = str(row.get("reason") or "").strip()
|
|
101
|
-
features = row.get("features", [])
|
|
102
|
-
confidence = row.get("confidence")
|
|
103
|
-
if not source or source in owners or not family or not variant or not reason:
|
|
104
|
-
raise ValueError(f"incomplete or duplicate mapping: {source!r}")
|
|
105
|
-
if confidence not in ("high", "medium", "low"):
|
|
106
|
-
raise ValueError(f"invalid confidence for {source}")
|
|
107
|
-
if not isinstance(features, list) or any(not isinstance(x, str) or not x.strip() for x in features):
|
|
108
|
-
raise ValueError(f"invalid features for {source}")
|
|
109
|
-
owners[source] = {"family": family, "variant": variant,
|
|
110
|
-
"features": list(dict.fromkeys(x.strip() for x in features)),
|
|
111
|
-
"reason": reason, "confidence": confidence}
|
|
112
|
-
if set(owners) != set(original_counts):
|
|
113
|
-
raise ValueError(f"mapping coverage differs: missing={sorted(set(original_counts)-set(owners))}, "
|
|
114
|
-
f"extra={sorted(set(owners)-set(original_counts))}")
|
|
115
|
-
if not decisions.get("semantic_review") or not decisions["semantic_review"].get("method"):
|
|
116
|
-
raise ValueError("semantic_review.method is required")
|
|
117
|
-
|
|
118
|
-
family_counts, variant_counts = Counter(), Counter()
|
|
119
|
-
family_examples = defaultdict(list)
|
|
120
|
-
family_sources = defaultdict(list)
|
|
121
|
-
for source, decision in owners.items():
|
|
122
|
-
family_sources[decision["family"]].append(source)
|
|
123
|
-
updated = []
|
|
124
|
-
for source_row in labels["records"]:
|
|
125
|
-
row = json.loads(json.dumps(source_row, ensure_ascii=False))
|
|
126
|
-
original = row["form"].get("label")
|
|
127
|
-
if original:
|
|
128
|
-
decision = owners[original]
|
|
129
|
-
family, variant = decision["family"], decision["variant"]
|
|
130
|
-
row["form"].update({"source_label": original, "label": family,
|
|
131
|
-
"variant": variant, "features": decision["features"],
|
|
132
|
-
"cluster_confidence": decision["confidence"]})
|
|
133
|
-
family_counts[family] += 1
|
|
134
|
-
variant_counts[(family, variant)] += 1
|
|
135
|
-
if len(family_examples[family]) < 3:
|
|
136
|
-
family_examples[family].append(row["product_id"])
|
|
137
|
-
updated.append(row)
|
|
138
|
-
if sum(family_counts.values()) != sum(original_counts.values()):
|
|
139
|
-
raise ValueError("product population changed during clustering")
|
|
140
|
-
result_summary = json.loads(json.dumps(summary, ensure_ascii=False))
|
|
141
|
-
axis = result_summary["axes"]["form"]
|
|
142
|
-
axis["source_canonical_counts"] = dict(original_counts)
|
|
143
|
-
axis["source_examples"] = axis["examples"]
|
|
144
|
-
axis["canonical_counts"] = dict(family_counts)
|
|
145
|
-
axis["examples"] = dict(family_examples)
|
|
146
|
-
axis["variant_counts"] = [{"family": family, "variant": variant, "products": count}
|
|
147
|
-
for (family, variant), count in sorted(variant_counts.items())]
|
|
148
|
-
axis["family_sources"] = {key: sorted(value) for key, value in family_sources.items()}
|
|
149
|
-
result_summary["strategy"] = "visual-selection-three-axis-hierarchical-v1"
|
|
150
|
-
result_summary["form_cluster"] = {"method": decisions["semantic_review"]["method"],
|
|
151
|
-
"source_class_count": len(original_counts),
|
|
152
|
-
"family_count": len(family_counts),
|
|
153
|
-
"variant_count": len(variant_counts),
|
|
154
|
-
"source_singletons": sum(count == 1 for count in original_counts.values()),
|
|
155
|
-
"family_singletons": sum(count == 1 for count in family_counts.values()),
|
|
156
|
-
"low_confidence_sources": [source for source, item in owners.items()
|
|
157
|
-
if item["confidence"] == "low"]}
|
|
158
|
-
result_summary["qa"]["form_cluster_mapping_complete"] = True
|
|
159
|
-
result_summary["qa"]["form_cluster_population_preserved"] = True
|
|
160
|
-
result_summary["qa"]["form_cluster_semantic_review_recorded"] = True
|
|
161
|
-
target = Path(output_dir)
|
|
162
|
-
if target.exists():
|
|
163
|
-
raise FileExistsError(target)
|
|
164
|
-
target.mkdir(parents=True)
|
|
165
|
-
save_new(target / "product_labels.json", {"schema_version": labels["schema_version"], "records": updated})
|
|
166
|
-
save_new(target / "label_summary.json", result_summary)
|
|
167
|
-
save_new(target / "cluster_audit.json", {"schema_version": 1, "mappings": rows,
|
|
168
|
-
"family_sources": result_summary["axes"]["form"]["family_sources"],
|
|
169
|
-
"counts": result_summary["form_cluster"],
|
|
170
|
-
"semantic_review": decisions["semantic_review"]})
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def main():
|
|
174
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
175
|
-
sub = parser.add_subparsers(dest="command", required=True)
|
|
176
|
-
first = sub.add_parser("prepare")
|
|
177
|
-
first.add_argument("--labels", required=True)
|
|
178
|
-
first.add_argument("--summary", required=True)
|
|
179
|
-
first.add_argument("--output", required=True)
|
|
180
|
-
second = sub.add_parser("apply")
|
|
181
|
-
second.add_argument("--labels", required=True)
|
|
182
|
-
second.add_argument("--summary", required=True)
|
|
183
|
-
second.add_argument("--decisions", required=True)
|
|
184
|
-
second.add_argument("--output-dir", required=True)
|
|
185
|
-
args = parser.parse_args()
|
|
186
|
-
if args.command == "prepare":
|
|
187
|
-
prepare(args.labels, args.summary, args.output)
|
|
188
|
-
else:
|
|
189
|
-
apply(args.labels, args.summary, args.decisions, args.output_dir)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if __name__ == "__main__":
|
|
193
|
-
main()
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Validate image-bound product-style decisions and compile reusable evidence."""
|
|
3
|
-
|
|
4
|
-
import argparse
|
|
5
|
-
import hashlib
|
|
6
|
-
import json
|
|
7
|
-
from collections import Counter
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def load(path):
|
|
12
|
-
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def digest(path):
|
|
16
|
-
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def require(condition, message):
|
|
20
|
-
if not condition:
|
|
21
|
-
raise ValueError(message)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def main():
|
|
25
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
26
|
-
parser.add_argument("--plan", required=True)
|
|
27
|
-
parser.add_argument("--observations", required=True)
|
|
28
|
-
parser.add_argument("--decisions", required=True)
|
|
29
|
-
parser.add_argument("--output-dir", required=True)
|
|
30
|
-
args = parser.parse_args()
|
|
31
|
-
|
|
32
|
-
plan, observations, decisions = map(load, (args.plan, args.observations, args.decisions))
|
|
33
|
-
require(decisions.get("schema_version") == 1, "decisions.schema_version must be 1")
|
|
34
|
-
require(decisions.get("source_plan_sha256") == digest(args.plan), "plan checksum mismatch")
|
|
35
|
-
require(decisions.get("source_observations_sha256") == digest(args.observations), "observations checksum mismatch")
|
|
36
|
-
items = {str(item["product_id"]): item for item in plan["items"] if item["status"] == "ready"}
|
|
37
|
-
require(len(items) == sum(item["status"] == "ready" for item in plan["items"]), "duplicate ready IDs")
|
|
38
|
-
raw = {str(item["product_id"]): item for item in observations["records"]}
|
|
39
|
-
require(len(raw) == len(observations["records"]) == len(items), "observation population mismatch")
|
|
40
|
-
require(set(raw) == set(items), "observation IDs differ from ready image IDs")
|
|
41
|
-
for product_id, item in items.items():
|
|
42
|
-
require(raw[product_id]["image_sha256"] == item["image_sha256"], f"raw image hash mismatch: {product_id}")
|
|
43
|
-
|
|
44
|
-
prototypes = decisions.get("prototypes", [])
|
|
45
|
-
prototype_ids = [entry["style_id"] for entry in prototypes]
|
|
46
|
-
require(len(prototype_ids) == len(set(prototype_ids)) and prototypes, "duplicate or missing prototypes")
|
|
47
|
-
for entry in prototypes:
|
|
48
|
-
for field in ("style_id", "name", "definition", "selection_difference", "boundary", "representative_ids"):
|
|
49
|
-
require(entry.get(field), f"prototype {entry.get('style_id')} lacks {field}")
|
|
50
|
-
require(isinstance(entry["representative_ids"], list), "representative_ids must be a list")
|
|
51
|
-
|
|
52
|
-
records = decisions.get("assignments", [])
|
|
53
|
-
assigned_ids = [str(row["product_id"]) for row in records]
|
|
54
|
-
require(len(records) == len(items) == len(set(assigned_ids)), "assignment population/duplicates mismatch")
|
|
55
|
-
require(set(assigned_ids) == set(items), "assignment IDs differ from ready image IDs")
|
|
56
|
-
by_id = {str(row["product_id"]): row for row in records}
|
|
57
|
-
groups = {style_id: [] for style_id in prototype_ids}
|
|
58
|
-
labels = []
|
|
59
|
-
uncertainty = []
|
|
60
|
-
for product_id, item in items.items():
|
|
61
|
-
row = by_id[product_id]
|
|
62
|
-
style_ids = row.get("style_ids", [])
|
|
63
|
-
boundary_reason = row.get("boundary_reason")
|
|
64
|
-
require(row.get("image_sha256") == item["image_sha256"], f"decision image hash mismatch: {product_id}")
|
|
65
|
-
require(isinstance(style_ids, list) and len(style_ids) == len(set(style_ids)), f"invalid style IDs: {product_id}")
|
|
66
|
-
require(all(style_id in groups for style_id in style_ids), f"unknown prototype: {product_id}")
|
|
67
|
-
require(bool(style_ids) != bool(boundary_reason), f"assign style(s) or boundary: {product_id}")
|
|
68
|
-
require(row.get("reason") and row.get("confidence") in ("high", "medium", "low"), f"missing reason/confidence: {product_id}")
|
|
69
|
-
for style_id in style_ids:
|
|
70
|
-
groups[style_id].append(product_id)
|
|
71
|
-
if row["confidence"] == "low" or boundary_reason:
|
|
72
|
-
uncertainty.append(product_id)
|
|
73
|
-
labels.append({
|
|
74
|
-
"product_id": product_id,
|
|
75
|
-
"image_path": item["image_path"],
|
|
76
|
-
"image_sha256": item["image_sha256"],
|
|
77
|
-
"source_rank": item.get("source_rank"),
|
|
78
|
-
"batch_id": item.get("batch_id"),
|
|
79
|
-
"style_ids": style_ids,
|
|
80
|
-
"variant_or_features": row.get("variant_or_features", []),
|
|
81
|
-
"reason": row["reason"],
|
|
82
|
-
"confidence": row["confidence"],
|
|
83
|
-
"boundary_reason": boundary_reason,
|
|
84
|
-
"visible_evidence": raw[product_id].get("visible_evidence"),
|
|
85
|
-
})
|
|
86
|
-
for entry in prototypes:
|
|
87
|
-
style_id = entry["style_id"]
|
|
88
|
-
require(groups[style_id], f"empty prototype: {style_id}")
|
|
89
|
-
require(all(str(pid) in groups[style_id] for pid in entry["representative_ids"]), f"representative outside group: {style_id}")
|
|
90
|
-
|
|
91
|
-
out = Path(args.output_dir)
|
|
92
|
-
require(not out.exists(), "output directory already exists; choose a new versioned path")
|
|
93
|
-
out.mkdir(parents=True)
|
|
94
|
-
summary = [{**entry, "product_count": len(groups[entry["style_id"]]), "product_ids": groups[entry["style_id"]]} for entry in prototypes]
|
|
95
|
-
audit = {
|
|
96
|
-
"source_plan_sha256": digest(args.plan),
|
|
97
|
-
"source_observations_sha256": digest(args.observations),
|
|
98
|
-
"source_decisions_sha256": digest(args.decisions),
|
|
99
|
-
"ready_count": len(items),
|
|
100
|
-
"assigned_count": len(labels),
|
|
101
|
-
"classified_count": sum(bool(row["style_ids"]) for row in labels),
|
|
102
|
-
"boundary_count": sum(bool(row["boundary_reason"]) for row in labels),
|
|
103
|
-
"multi_style_count": sum(len(row["style_ids"]) > 1 for row in labels),
|
|
104
|
-
"memberships": sum(len(row["style_ids"]) for row in labels),
|
|
105
|
-
"prototype_count": len(prototypes),
|
|
106
|
-
"confidence_counts": dict(Counter(row["confidence"] for row in labels)),
|
|
107
|
-
"review_ids": uncertainty,
|
|
108
|
-
}
|
|
109
|
-
for name, data in (("product_style_labels.json", labels), ("style_prototype_summary.json", summary), ("style_prototype_audit.json", audit)):
|
|
110
|
-
(out / name).write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
111
|
-
print(json.dumps({"output_dir": str(out), **{key: audit[key] for key in ("ready_count", "classified_count", "boundary_count", "prototype_count", "memberships")}}, ensure_ascii=False))
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if __name__ == "__main__":
|
|
115
|
-
main()
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Compile a visually reviewed position ledger to product/image-linked labels."""
|
|
3
|
-
|
|
4
|
-
import argparse
|
|
5
|
-
import json
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def main():
|
|
10
|
-
parser = argparse.ArgumentParser()
|
|
11
|
-
parser.add_argument("--manifest", required=True)
|
|
12
|
-
parser.add_argument("--review", required=True)
|
|
13
|
-
parser.add_argument("--output", required=True)
|
|
14
|
-
args = parser.parse_args()
|
|
15
|
-
output = Path(args.output).expanduser().resolve()
|
|
16
|
-
if output.exists():
|
|
17
|
-
parser.error("output already exists; choose a new path")
|
|
18
|
-
manifest = json.loads(Path(args.manifest).read_text(encoding="utf-8"))
|
|
19
|
-
review = json.loads(Path(args.review).read_text(encoding="utf-8"))
|
|
20
|
-
products = {p["product_id"]: p for p in manifest["products"]}
|
|
21
|
-
selected = manifest["selected_organic_ids"] + manifest["selected_ad_only_ids"]
|
|
22
|
-
positions = {}
|
|
23
|
-
for pid in selected:
|
|
24
|
-
p = products[pid]
|
|
25
|
-
key = "N" + str(p["first_natural"]) if p["first_natural"] else "A" + str(p["first_ad"])
|
|
26
|
-
if key in positions:
|
|
27
|
-
raise ValueError(f"duplicate display-position key: {key}")
|
|
28
|
-
positions[key] = pid
|
|
29
|
-
labeled = {}
|
|
30
|
-
for group in review["groups"]:
|
|
31
|
-
eligibility = group["eligibility"]
|
|
32
|
-
form = group.get("form")
|
|
33
|
-
evidence = group.get("visible_evidence")
|
|
34
|
-
for key in group["positions"]:
|
|
35
|
-
if key not in positions or key in labeled:
|
|
36
|
-
raise ValueError(f"unknown or duplicate reviewed position: {key}")
|
|
37
|
-
pid = positions[key]
|
|
38
|
-
fetch = manifest["fetches"].get(pid, {})
|
|
39
|
-
if fetch.get("status") != "ok":
|
|
40
|
-
raise ValueError(f"image unavailable for {key}")
|
|
41
|
-
labeled[key] = {
|
|
42
|
-
"product_id": pid,
|
|
43
|
-
"image_path": fetch["path"],
|
|
44
|
-
"eligibility": eligibility,
|
|
45
|
-
"form": form if eligibility == "target" else None,
|
|
46
|
-
"visible_evidence": evidence,
|
|
47
|
-
"confidence": "medium" if eligibility == "uncertain" else "high",
|
|
48
|
-
"review_position": key,
|
|
49
|
-
}
|
|
50
|
-
missing = set(positions) - set(labeled)
|
|
51
|
-
if missing:
|
|
52
|
-
raise ValueError(f"review is incomplete; missing: {sorted(missing)}")
|
|
53
|
-
output.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
-
with output.open("x", encoding="utf-8") as handle:
|
|
55
|
-
json.dump({"schema_version": 1, "search_term": manifest["search_term"], "scope": manifest["scope"], "review_basis": review["review_basis"], "labels": list(labeled.values())}, handle, ensure_ascii=False, indent=2)
|
|
56
|
-
print(json.dumps({"output": str(output), "labels": len(labeled)}, ensure_ascii=False))
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if __name__ == "__main__":
|
|
60
|
-
main()
|