@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.
Files changed (77) hide show
  1. package/README.md +30 -22
  2. package/package.json +2 -7
  3. package/requirements.txt +1 -0
  4. package/skills/taobao-ai-operations/SKILL.md +8 -1
  5. package/src/cli.mjs +16 -6
  6. package/src/components.mjs +73 -0
  7. package/src/core.mjs +5 -1
  8. package/src/runtime.mjs +18 -16
  9. package/src/skill-manager.mjs +4 -4
  10. package/src/update.mjs +5 -5
  11. package/src/workflow.mjs +3 -3
  12. package/suite-manifest.json +186 -10
  13. package/skills/taobao-search-product-form/SKILL.md +0 -74
  14. package/skills/taobao-search-product-form/adapters/codex.json +0 -139
  15. package/skills/taobao-search-product-form/adapters/sealseek.json +0 -24
  16. package/skills/taobao-search-product-form/agents/openai.yaml +0 -3
  17. package/skills/taobao-search-product-form/capabilities.json +0 -148
  18. package/skills/taobao-search-product-form/references/SCHEMA.md +0 -3
  19. package/skills/taobao-search-product-form/references/index.md +0 -10
  20. package/skills/taobao-search-product-form/references/log.md +0 -35
  21. package/skills/taobao-search-product-form/references/queries/evaluate-white-label-forms.md +0 -3
  22. package/skills/taobao-search-product-form/references/queries/identify-advertisers.md +0 -5
  23. package/skills/taobao-search-product-form/references/queries/identify-geography.md +0 -5
  24. package/skills/taobao-search-product-form/references/queries/identify-price-bands.md +0 -3
  25. package/skills/taobao-search-product-form/references/queries/identify-product-forms.md +0 -3
  26. package/skills/taobao-search-product-form/references/queries/identify-title-roots.md +0 -5
  27. package/skills/taobao-search-product-form/references/report-output.md +0 -23
  28. package/skills/taobao-search-product-form/references/source-manifest.md +0 -37
  29. package/skills/taobao-search-product-form/references/title-lexicon.json +0 -55
  30. package/skills/taobao-search-product-form/references/topics/advertiser-analysis-method.md +0 -43
  31. package/skills/taobao-search-product-form/references/topics/geography-analysis-method.md +0 -23
  32. package/skills/taobao-search-product-form/references/topics/price-band-analysis-method.md +0 -27
  33. package/skills/taobao-search-product-form/references/topics/title-root-analysis-method.md +0 -37
  34. package/skills/taobao-search-product-form/references/topics/visual-form-method.md +0 -31
  35. package/skills/taobao-search-product-form/references/topics/white-label-evidence.md +0 -38
  36. package/skills/taobao-search-product-form/references/workflow-ledger.md +0 -28
  37. package/skills/taobao-search-product-form/report_runtime/VERSION +0 -1
  38. package/skills/taobao-search-product-form/report_runtime/renderer.py +0 -485
  39. package/skills/taobao-search-product-form/report_runtime/templates/workbench.html +0 -67
  40. package/skills/taobao-search-product-form/report_runtime/validator.py +0 -117
  41. package/skills/taobao-search-product-form/scripts/analyze.py +0 -119
  42. package/skills/taobao-search-product-form/scripts/analyze_advertisers.py +0 -352
  43. package/skills/taobao-search-product-form/scripts/analyze_geography.py +0 -316
  44. package/skills/taobao-search-product-form/scripts/analyze_price_bands.py +0 -257
  45. package/skills/taobao-search-product-form/scripts/analyze_title_roots.py +0 -327
  46. package/skills/taobao-search-product-form/scripts/assemble_opportunity_evidence.py +0 -322
  47. package/skills/taobao-search-product-form/scripts/build_report_viewmodel.py +0 -822
  48. package/skills/taobao-search-product-form/scripts/build_visual_review_viewmodel.py +0 -177
  49. package/skills/taobao-search-product-form/scripts/clean_search_export.mjs +0 -61
  50. package/skills/taobao-search-product-form/scripts/cluster_visual_forms.py +0 -193
  51. package/skills/taobao-search-product-form/scripts/compile_style_prototypes.py +0 -115
  52. package/skills/taobao-search-product-form/scripts/compile_visual_review.py +0 -60
  53. package/skills/taobao-search-product-form/scripts/compile_visual_selection.py +0 -148
  54. package/skills/taobao-search-product-form/scripts/prepare.py +0 -180
  55. package/skills/taobao-search-product-form/scripts/render_report.py +0 -31
  56. package/skills/taobao-search-product-form/scripts/validate_opportunity_cards.py +0 -84
  57. package/skills/taobao-search-product-form/scripts/validate_report_viewmodel.py +0 -152
  58. package/skills/taobao-search-product-form/scripts/visual_form_pipeline.py +0 -247
  59. package/skills/taobao-search-product-form/scripts/visual_observation_batches.py +0 -117
  60. package/skills/taobao-search-product-form/scripts/workflow_ledger.py +0 -177
  61. package/skills/yuce-category-opportunity-report/SKILL.md +0 -151
  62. package/skills/yuce-category-opportunity-report/adapters/codex.json +0 -113
  63. package/skills/yuce-category-opportunity-report/adapters/sealseek.json +0 -39
  64. package/skills/yuce-category-opportunity-report/agents/openai.yaml +0 -3
  65. package/skills/yuce-category-opportunity-report/assets/chart-views.js +0 -80
  66. package/skills/yuce-category-opportunity-report/assets/package.json +0 -1
  67. package/skills/yuce-category-opportunity-report/assets/report-template.html +0 -62
  68. package/skills/yuce-category-opportunity-report/capabilities.json +0 -48
  69. package/skills/yuce-category-opportunity-report/references/SCHEMA.md +0 -5
  70. package/skills/yuce-category-opportunity-report/references/data-contract.md +0 -40
  71. package/skills/yuce-category-opportunity-report/references/index.md +0 -6
  72. package/skills/yuce-category-opportunity-report/references/log.md +0 -29
  73. package/skills/yuce-category-opportunity-report/references/queries/assess-category-opportunity.md +0 -7
  74. package/skills/yuce-category-opportunity-report/references/source-manifest.md +0 -14
  75. package/skills/yuce-category-opportunity-report/references/topics/category-opportunity-method.md +0 -39
  76. package/skills/yuce-category-opportunity-report/scripts/build_report.py +0 -779
  77. package/skills/yuce-category-opportunity-report/scripts/market_routes.py +0 -139
@@ -1,327 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Build auditable product-title roots from a cleaned natural-position sheet.
3
-
4
- Requires pandas, an XLSX reader, and a Python package exposing ``jieba``.
5
- Writes a new output directory; never changes the workbook or shared lexicon.
6
- """
7
-
8
- import argparse
9
- import hashlib
10
- import importlib.metadata
11
- import itertools
12
- import json
13
- import re
14
- import unicodedata
15
- from collections import Counter, defaultdict
16
- from pathlib import Path
17
-
18
- import pandas as pd
19
-
20
- try:
21
- import jieba
22
- except ImportError as error:
23
- raise SystemExit("jieba is required; install the documented jieba-compatible Python package") from error
24
-
25
-
26
- REQUIRED = {"商品ID", "商品名称", "商品图片链接", "现价", "付款人数", "店铺名称", "占位类型"}
27
- DEFAULT_LEXICON = Path(__file__).resolve().parents[1] / "references" / "title-lexicon.json"
28
- WORDLIKE = re.compile(r"[\u3400-\u9fffA-Za-z0-9]")
29
- SIZE = re.compile(r"(?<!\d)(\d{2}(?:\.\d+)?)\s*(cm|厘米|寸)(?![A-Za-z])", re.IGNORECASE)
30
-
31
-
32
- def clean_text(value):
33
- return "" if pd.isna(value) else str(value).strip()
34
-
35
-
36
- def normalized(value):
37
- return unicodedata.normalize("NFKC", clean_text(value))
38
-
39
-
40
- def optional_number(value):
41
- numeric = pd.to_numeric(value, errors="coerce")
42
- return None if pd.isna(numeric) else float(numeric)
43
-
44
-
45
- def file_sha(path):
46
- return hashlib.sha256(path.read_bytes()).hexdigest()
47
-
48
-
49
- def load_lexicon(base_path, project_path=None):
50
- paths = [base_path] + ([project_path] if project_path else [])
51
- entries = []
52
- surface_map = {}
53
- sources = []
54
- for path in paths:
55
- document = json.loads(path.read_text(encoding="utf-8"))
56
- if document.get("schema_version") != "1.0" or not isinstance(document.get("entries"), list):
57
- raise ValueError(f"invalid title lexicon schema: {path}")
58
- sources.append({"file_name": path.name, "sha256": file_sha(path), "entry_count": len(document["entries"])})
59
- for raw in document["entries"]:
60
- canonical = normalized(raw.get("canonical"))
61
- facet = clean_text(raw.get("facet"))
62
- surfaces = raw.get("surfaces")
63
- if not canonical or not facet or not isinstance(surfaces, list) or not surfaces:
64
- raise ValueError(f"invalid lexicon entry in {path}: {raw}")
65
- item = {"canonical": canonical, "facet": facet, "surfaces": []}
66
- for raw_surface in surfaces:
67
- surface = normalized(raw_surface)
68
- if not surface or not WORDLIKE.search(surface):
69
- raise ValueError(f"invalid lexicon surface in {path}: {raw_surface}")
70
- key = surface.casefold()
71
- current = surface_map.get(key)
72
- if current and current != (canonical, facet):
73
- raise ValueError(f"conflicting lexicon surface {surface}: {current} versus {(canonical, facet)}")
74
- surface_map[key] = (canonical, facet)
75
- if surface not in item["surfaces"]:
76
- item["surfaces"].append(surface)
77
- entries.append(item)
78
- return {"schema_version": "1.0", "entries": entries, "sources": sources}
79
-
80
-
81
- def match_title(title, lexicon, tokenizer):
82
- """Return longest mapped phrases per facet plus unclaimed jieba candidates."""
83
- matches = []
84
- occupied_by_facet = defaultdict(set)
85
- surfaces = [
86
- (surface, entry["canonical"], entry["facet"])
87
- for entry in lexicon["entries"] for surface in entry["surfaces"]
88
- ]
89
- for surface, canonical, facet in sorted(surfaces, key=lambda item: (-len(item[0]), item[0])):
90
- lower_title = title.casefold()
91
- lower_surface = surface.casefold()
92
- start = 0
93
- while True:
94
- found = lower_title.find(lower_surface, start)
95
- if found < 0:
96
- break
97
- end = found + len(surface)
98
- if not any(index in occupied_by_facet[facet] for index in range(found, end)):
99
- matches.append({"root": canonical, "facet": facet, "surface": title[found:end], "start": found, "end": end, "source": "lexicon"})
100
- occupied_by_facet[facet].update(range(found, end))
101
- start = found + 1
102
-
103
- protected = set().union(*occupied_by_facet.values()) if occupied_by_facet else set()
104
- for found in SIZE.finditer(title):
105
- root = f"{found.group(1)}{found.group(2).lower()}"
106
- matches.append({"root": root, "facet": "size_claim", "surface": found.group(), "start": found.start(), "end": found.end(), "source": "size_pattern"})
107
- protected.update(range(found.start(), found.end()))
108
- for word, start, end in tokenizer.tokenize(title, HMM=False):
109
- candidate = word.strip()
110
- if not candidate or not WORDLIKE.search(candidate) or all(char.isdigit() for char in candidate):
111
- continue
112
- if any(index in protected for index in range(start, end)):
113
- continue
114
- if len(candidate) < 2:
115
- continue
116
- matches.append({"root": candidate.casefold(), "facet": "unclassified", "surface": word, "start": start, "end": end, "source": "jieba_candidate"})
117
-
118
- return sorted(matches, key=lambda item: (item["start"], item["end"], item["facet"], item["root"]))
119
-
120
-
121
- def percentile(values, quantile):
122
- numeric = pd.Series([value for value in values if value is not None], dtype="float64")
123
- return None if numeric.empty else round(float(numeric.quantile(quantile)), 4)
124
-
125
-
126
- def analyze_frame(frame, lexicon):
127
- missing = REQUIRED - set(frame.columns)
128
- if missing:
129
- raise ValueError(f"natural-position sheet missing columns: {sorted(missing)}")
130
- if frame.empty:
131
- raise ValueError("natural-position sheet is empty")
132
- ids = frame["商品ID"].map(clean_text)
133
- if ids.eq("").any() or ids.duplicated().any():
134
- raise ValueError("natural-position product IDs must be nonblank and unique")
135
- if not frame["占位类型"].map(clean_text).eq("自然位").all():
136
- raise ValueError("title analysis accepts natural-position rows only")
137
-
138
- tokenizer = jieba.Tokenizer()
139
- for entry in lexicon["entries"]:
140
- for surface in entry["surfaces"]:
141
- tokenizer.add_word(surface, freq=2_000_000)
142
-
143
- products = []
144
- relations = []
145
- for _, row in frame.iterrows():
146
- product_id = clean_text(row["商品ID"])
147
- title = clean_text(row["商品名称"])
148
- title_normalized = normalized(title)
149
- product = {
150
- "product_id": product_id,
151
- "title": title,
152
- "title_normalized": title_normalized,
153
- "image_url": clean_text(row["商品图片链接"]),
154
- "price_yuan": optional_number(row["现价"]),
155
- "payment_people": optional_number(row["付款人数"]),
156
- "shop_name": clean_text(row["店铺名称"]),
157
- "shop_type": clean_text(row.get("店铺类型")) or None,
158
- "natural_position": optional_number(row.get("序号")),
159
- }
160
- if not title_normalized or not product["shop_name"]:
161
- raise ValueError(f"natural-position product {product_id} has a blank title or shop name")
162
- products.append(product)
163
- grouped = defaultdict(list)
164
- for match in match_title(title_normalized, lexicon, tokenizer):
165
- grouped[(match["facet"], match["root"])].append(match)
166
- for (facet, root), occurrences in sorted(grouped.items()):
167
- relations.append({
168
- "product_id": product_id,
169
- "root": root,
170
- "facet": facet,
171
- "surfaces": sorted({hit["surface"] for hit in occurrences}),
172
- "occurrence_count_in_title": len(occurrences),
173
- "spans_in_normalized_title": [[hit["start"], hit["end"]] for hit in occurrences],
174
- "sources": sorted({hit["source"] for hit in occurrences}),
175
- "title": title,
176
- "image_url": product["image_url"],
177
- "price_yuan": product["price_yuan"],
178
- "payment_people": product["payment_people"],
179
- "shop_name": product["shop_name"],
180
- "shop_type": product["shop_type"],
181
- })
182
-
183
- by_id = {row["product_id"]: row for row in products}
184
- payment_threshold = percentile([row["payment_people"] for row in products], 0.9)
185
- by_root = defaultdict(list)
186
- by_shop = defaultdict(list)
187
- mapped_by_product = defaultdict(set)
188
- for relation in relations:
189
- by_root[(relation["facet"], relation["root"])].append(relation)
190
- if relation["facet"] != "unclassified":
191
- mapped_by_product[relation["product_id"]].add((relation["facet"], relation["root"]))
192
- by_shop[relation["shop_name"]].append(relation)
193
-
194
- roots = []
195
- for (facet, root), rows in by_root.items():
196
- prices = [row["price_yuan"] for row in rows]
197
- payments = [row["payment_people"] for row in rows]
198
- shop_groups = defaultdict(list)
199
- for row in rows:
200
- shop_groups[row["shop_name"]].append(row)
201
- top_shops = sorted(
202
- ({"shop_name": shop, "product_count": len(group), "displayed_payment_people_sum": round(sum(item["payment_people"] or 0 for item in group), 2)} for shop, group in shop_groups.items()),
203
- key=lambda item: (-item["product_count"], -item["displayed_payment_people_sum"], item["shop_name"]),
204
- )[:5]
205
- high = sum(value is not None and payment_threshold is not None and value >= payment_threshold for value in payments)
206
- roots.append({
207
- "root": root, "facet": facet, "product_count": len(rows),
208
- "product_share": round(len(rows) / len(products), 6),
209
- "shop_count": len(shop_groups),
210
- "price_p25": percentile(prices, 0.25), "price_median": percentile(prices, 0.5), "price_p75": percentile(prices, 0.75),
211
- "payment_people_median": percentile(payments, 0.5),
212
- "payment_people_p75": percentile(payments, 0.75),
213
- "displayed_payment_people_sum_within_root": round(sum(value or 0 for value in payments), 2),
214
- "high_payment_product_count": high,
215
- "high_payment_product_share": round(high / len(rows), 6),
216
- "top_shops_by_product_count": top_shops,
217
- })
218
- roots.sort(key=lambda item: (-item["product_count"], item["facet"], item["root"]))
219
- root_counts = {(item["facet"], item["root"]): item["product_count"] for item in roots}
220
-
221
- shops = []
222
- for shop_name, group in frame.groupby("店铺名称", sort=False):
223
- shop_ids = {clean_text(value) for value in group["商品ID"]}
224
- shop_relations = by_shop.get(shop_name, [])
225
- root_groups = defaultdict(list)
226
- for relation in shop_relations:
227
- root_groups[(relation["facet"], relation["root"])].append(relation)
228
- shop_roots = sorted(
229
- ({"facet": facet, "root": root, "product_count": len(rows), "displayed_payment_people_sum_within_root": round(sum(row["payment_people"] or 0 for row in rows), 2)} for (facet, root), rows in root_groups.items()),
230
- key=lambda item: (-item["product_count"], -item["displayed_payment_people_sum_within_root"], item["root"]),
231
- )
232
- distinctive = []
233
- for item in shop_roots:
234
- corpus_count = root_counts[(item["facet"], item["root"])]
235
- if item["product_count"] < 2 or corpus_count < 5:
236
- continue
237
- shop_share = item["product_count"] / len(shop_ids)
238
- corpus_share = corpus_count / len(products)
239
- distinctive.append({
240
- **item,
241
- "shop_product_share": round(shop_share, 6),
242
- "corpus_product_share": round(corpus_share, 6),
243
- "shop_to_corpus_coverage_ratio": round(shop_share / corpus_share, 4),
244
- })
245
- distinctive.sort(key=lambda item: (-item["shop_to_corpus_coverage_ratio"], -item["product_count"], item["root"]))
246
- shops.append({
247
- "shop_name": shop_name,
248
- "natural_product_count": len(shop_ids),
249
- "displayed_payment_people_sum_across_visible_products": round(sum(by_id[pid]["payment_people"] or 0 for pid in shop_ids), 2),
250
- "top_mapped_roots": shop_roots[:15],
251
- "top_distinctive_roots": distinctive[:15],
252
- })
253
- shops.sort(key=lambda item: (-item["natural_product_count"], item["shop_name"]))
254
-
255
- pairs = Counter()
256
- for roots_in_product in mapped_by_product.values():
257
- for first, second in itertools.combinations(sorted(roots_in_product), 2):
258
- if first[0] != second[0]:
259
- pairs[(first, second)] += 1
260
- pair_rows = [
261
- {"first": {"facet": first[0], "root": first[1]}, "second": {"facet": second[0], "root": second[1]}, "product_count": count}
262
- for (first, second), count in sorted(pairs.items(), key=lambda item: (-item[1], item[0]))
263
- if count >= 5
264
- ][:500]
265
-
266
- candidate_rows = [
267
- {"candidate": root["root"], "product_count": root["product_count"], "shop_count": root["shop_count"], "example_product_ids": [row["product_id"] for row in by_root[("unclassified", root["root"])][:5]]}
268
- for root in roots if root["facet"] == "unclassified" and root["product_count"] >= 3
269
- ][:500]
270
- quality = {
271
- "natural_product_count": len(products),
272
- "distinct_product_ids": len(by_id),
273
- "product_root_rows": len(relations),
274
- "products_with_mapped_root": len(mapped_by_product),
275
- "mapped_product_root_rows": sum(row["facet"] != "unclassified" for row in relations),
276
- "candidate_product_root_rows": sum(row["facet"] == "unclassified" for row in relations),
277
- "missing_price_count": sum(row["price_yuan"] is None for row in products),
278
- "missing_payment_count": sum(row["payment_people"] is None for row in products),
279
- "payment_high_threshold_p90": payment_threshold,
280
- "actual_products_at_or_above_p90": sum(row["payment_people"] is not None and row["payment_people"] >= payment_threshold for row in products) if payment_threshold is not None else 0,
281
- }
282
- if len(products) != len(by_id) or any(len({row["product_id"] for row in group}) != len(group) for group in by_root.values()):
283
- raise ValueError("product/root identity reconciliation failed")
284
- return {"products": products, "product_roots": relations, "roots": roots, "shops": shops, "root_pairs": pair_rows, "candidate_terms": candidate_rows, "quality": quality}
285
-
286
-
287
- def main():
288
- parser = argparse.ArgumentParser(description=__doc__)
289
- parser.add_argument("--input", required=True, help="Cleaned XLSX with a 自然位 sheet")
290
- parser.add_argument("--output-dir", required=True, help="New directory for title-root JSON artifacts")
291
- parser.add_argument("--project-lexicon", help="Optional reviewed lexicon JSON for this project; never modified")
292
- args = parser.parse_args()
293
- source = Path(args.input).expanduser().resolve()
294
- output = Path(args.output_dir).expanduser().resolve()
295
- project = Path(args.project_lexicon).expanduser().resolve() if args.project_lexicon else None
296
- if not source.is_file() or source.suffix.lower() != ".xlsx":
297
- parser.error("input must be an existing XLSX file")
298
- if output.exists():
299
- parser.error("output directory already exists")
300
- if project and not project.is_file():
301
- parser.error("project lexicon must be an existing JSON file")
302
- lexicon = load_lexicon(DEFAULT_LEXICON, project)
303
- frame = pd.read_excel(source, sheet_name="自然位", dtype={"商品ID": str})
304
- result = analyze_frame(frame, lexicon)
305
- try:
306
- jieba_version = importlib.metadata.version("jieba-py")
307
- distribution = "jieba-py"
308
- except importlib.metadata.PackageNotFoundError:
309
- jieba_version = importlib.metadata.version("jieba")
310
- distribution = "jieba"
311
- source_meta = {"file_name": source.name, "sha256": file_sha(source), "sheet": "自然位", "jieba_distribution": distribution, "jieba_version": jieba_version}
312
- output.mkdir(parents=True, exist_ok=False)
313
- artifacts = {
314
- "product_roots.json": {"schema_version": "1.0", "source": source_meta, "products": result["products"], "product_roots": result["product_roots"]},
315
- "root_summary.json": {"schema_version": "1.0", "source": source_meta, "definitions": {"root_coverage": "Distinct natural-position product IDs containing this title-root claim; not buyer search-query volume.", "payment": "Source-sheet 付款人数 with unspecified period; a product can belong to several roots, so sums across roots are non-additive.", "form": "Title-stated form only; real product form requires image review.", "shop_to_corpus_coverage_ratio": "Share of one shop's visible products using a root divided by the share of all visible products using that root; descriptive, not proof of merchant intent."}, "quality": result["quality"], "roots": result["roots"], "shops": result["shops"], "root_pairs": result["root_pairs"]},
316
- "lexicon_snapshot.json": {"schema_version": "1.0", "source": source_meta, "dictionary_policy": "Versioned bundled vocabulary plus optional reviewed project vocabulary; each run stores a snapshot and candidates but never mutates either lexicon.", **lexicon},
317
- "lexicon_candidates.json": {"schema_version": "1.0", "source": source_meta, "promotion_policy": "Candidates need semantic review before inclusion in a later versioned lexicon.", "candidates": result["candidate_terms"]},
318
- }
319
- for filename, document in artifacts.items():
320
- with (output / filename).open("x", encoding="utf-8") as handle:
321
- json.dump(document, handle, ensure_ascii=False, indent=2)
322
- handle.write("\n")
323
- print(json.dumps({"output_dir": str(output), "files": list(artifacts), "quality": result["quality"]}, ensure_ascii=False))
324
-
325
-
326
- if __name__ == "__main__":
327
- main()
@@ -1,322 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Join approved search modules into product-keyed opportunity evidence.
3
-
4
- This script describes observed groups; it does not score or recommend entry.
5
- Inputs are local stage artifacts. The output path must not exist.
6
- """
7
-
8
- import argparse
9
- import hashlib
10
- import json
11
- import statistics
12
- from collections import Counter, defaultdict
13
- from pathlib import Path
14
-
15
-
16
- def read_json(path):
17
- return json.loads(Path(path).read_text(encoding="utf-8"))
18
-
19
-
20
- def digest(path):
21
- return hashlib.sha256(Path(path).read_bytes()).hexdigest()
22
-
23
-
24
- def unique_rows(rows, label):
25
- result = {}
26
- for row in rows:
27
- key = str(row.get("product_id", "")).strip()
28
- if not key or key in result:
29
- raise ValueError(f"{label}: blank or duplicate product_id: {key!r}")
30
- result[key] = row
31
- return result
32
-
33
-
34
- def percentile(values, fraction):
35
- if not values:
36
- return None
37
- ordered = sorted(values)
38
- position = (len(ordered) - 1) * fraction
39
- lo = int(position)
40
- hi = min(lo + 1, len(ordered) - 1)
41
- return round(ordered[lo] + (ordered[hi] - ordered[lo]) * (position - lo), 2)
42
-
43
-
44
- def number(value, label):
45
- try:
46
- result = float(value)
47
- except (TypeError, ValueError) as exc:
48
- raise ValueError(f"invalid {label}: {value!r}") from exc
49
- if result < 0 or result != result or result == float("inf"):
50
- raise ValueError(f"invalid {label}: {value!r}")
51
- return result
52
-
53
-
54
- def same_fact(left, right):
55
- if isinstance(left, (int, float)) and isinstance(right, (int, float)):
56
- return abs(float(left) - float(right)) < 0.011
57
- return left == right
58
-
59
-
60
- def candidate_id(axis, labels):
61
- payload = json.dumps([axis, labels], ensure_ascii=False, separators=(",", ":"))
62
- return "c-" + hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
63
-
64
-
65
- def build(args):
66
- if not getattr(args, "prototype_labels", None):
67
- raise ValueError("whole-product prototype labels are required for cross-module comparison")
68
- paths = {"visual": args.visual_labels, "price": args.price_detail,
69
- "title": args.title_roots, "geography": args.geography_detail,
70
- "advertising": args.advertiser_detail}
71
- data = {name: read_json(path) for name, path in paths.items()}
72
- price = data["price"]
73
- roots = data["title"]
74
- geo = data["geography"]
75
- ads = data["advertising"]
76
- visual = data["visual"]
77
-
78
- source_hashes = {"price": price["source"]["sha256"],
79
- "title": roots["source"]["sha256"],
80
- "geography": geo["source"]["sha256"],
81
- "advertising": ads["source"]["sha256"]}
82
- if len(set(source_hashes.values())) != 1:
83
- raise ValueError(f"cleaned-workbook source hash mismatch: {source_hashes}")
84
-
85
- base = unique_rows(price["products"], "price products")
86
- title = unique_rows(roots["products"], "title products")
87
- location = unique_rows(geo["natural_products"], "geography products")
88
- observed = unique_rows(visual["records"], "visual records")
89
- prototype_rows = unique_rows(read_json(args.prototype_labels), "prototype labels")
90
- if set(base) != set(title) or set(base) != set(location):
91
- raise ValueError("price, title and geography natural product IDs differ")
92
- if not set(observed).issubset(base):
93
- raise ValueError("visual IDs are not a subset of cleaned natural products")
94
- if set(prototype_rows) != set(observed):
95
- raise ValueError("prototype labels and visual records must cover the same image IDs")
96
- for pid, row in prototype_rows.items():
97
- if row["image_sha256"] != observed[pid]["image_sha256"]:
98
- raise ValueError(f"prototype image hash disagrees for {pid}")
99
- if bool(row.get("style_ids")) == bool(row.get("boundary_reason")):
100
- raise ValueError(f"prototype label needs style membership or boundary reason: {pid}")
101
-
102
- for pid, row in base.items():
103
- for name, item, fields in (
104
- ("title", title[pid], ("title", "shop_name", "price_yuan", "payment_people")),
105
- ("geography", location[pid], ("title", "shop_name", "price_yuan", "payment_people")),
106
- ):
107
- for field in fields:
108
- if not same_fact(row[field], item[field]):
109
- raise ValueError(f"{name} disagrees on {field} for product {pid}")
110
-
111
- roots_by_id = defaultdict(set)
112
- for relation in roots["product_roots"]:
113
- pid = str(relation["product_id"])
114
- if pid not in base:
115
- raise ValueError(f"title root references unknown product {pid}")
116
- roots_by_id[pid].add((relation["facet"], relation["root"]))
117
-
118
- ads_by_id = defaultdict(list)
119
- ads_by_shop = defaultdict(list)
120
- for row in ads["ad_appearance_records"]:
121
- pid = str(row["product_id"])
122
- ads_by_id[pid].append(row)
123
- ads_by_shop[row["shop_name"]].append(row)
124
- if len(ads["ad_appearance_records"]) != ads["counts"]["ad_appearances"]:
125
- raise ValueError("advertising appearance count does not reconcile")
126
- if len(ads_by_id) != ads["counts"]["ad_products"]:
127
- raise ValueError("advertising unique-product count does not reconcile")
128
-
129
- products = []
130
- for pid, row in base.items():
131
- v = observed.get(pid)
132
- status = v["status"] if v else "not_observed"
133
- labels = {axis: v[axis].get("label") if v else None
134
- for axis in ("form", "style", "audience")}
135
- form_variant = v["form"].get("variant") if v else None
136
- form_source_label = v["form"].get("source_label") if v else None
137
- form_features = v["form"].get("features", []) if v else []
138
- if status == "tagged" and not labels["form"]:
139
- raise ValueError(f"tagged product {pid} lacks a form")
140
- price_yuan = number(row["price_yuan"], "price_yuan")
141
- payment = number(row["payment_people"], "payment_people")
142
- ad_rows = ads_by_id.get(pid, [])
143
- shop = row["shop_name"]
144
- products.append({
145
- "product_id": pid,
146
- "title": row["title"],
147
- "image_url": title[pid].get("image_url"),
148
- "image_path": v.get("image_path") if v else None,
149
- "visual_status": status,
150
- "form": labels["form"], "form_variant": form_variant,
151
- "product_style_ids": prototype_rows.get(pid, {}).get("style_ids", []),
152
- "product_style_boundary": prototype_rows.get(pid, {}).get("boundary_reason"),
153
- "form_source_label": form_source_label, "form_features": form_features,
154
- "style": labels["style"],
155
- "task_audience": labels["audience"],
156
- "visible_evidence": v.get("visible_evidence") if v else None,
157
- "style_product_delta": v["style"].get("physical_delta") if v else None,
158
- "task_product_delta": v["audience"].get("physical_delta") if v else None,
159
- "shop_name": shop,
160
- "shop_type_label": title[pid].get("shop_type"),
161
- "price_yuan": price_yuan, "price_band": row["band"],
162
- "payment_people_displayed": payment,
163
- "natural_position": title[pid].get("natural_position"),
164
- "listing_address": location[pid].get("address_raw"),
165
- "title_roots": [{"facet": facet, "root": root}
166
- for facet, root in sorted(roots_by_id[pid])],
167
- "ad_appearances_same_shop": sum(a["shop_name"] == shop for a in ad_rows),
168
- "ad_appearances_other_shop": sum(a["shop_name"] != shop for a in ad_rows),
169
- })
170
-
171
- by_id = {p["product_id"]: p for p in products}
172
- by_shop = defaultdict(list)
173
- for p in products:
174
- by_shop[p["shop_name"]].append(p)
175
-
176
- member_sets = defaultdict(set)
177
- definitions = {}
178
- for p in products:
179
- if p["visual_status"] != "tagged" and not p["product_style_ids"]:
180
- continue
181
- axes = [("product_style", {"product_style_id": style_id}) for style_id in p["product_style_ids"]]
182
- if p["visual_status"] != "tagged":
183
- for axis, labels in axes:
184
- cid = candidate_id(axis, labels)
185
- member_sets[cid].add(p["product_id"])
186
- definitions[cid] = (axis, labels)
187
- continue
188
- axes.append(("form", {"form": p["form"]}))
189
- if p["form_variant"]:
190
- axes.append(("form_variant", {"form": p["form"], "variant": p["form_variant"]}))
191
- if p["style"]:
192
- axes.append(("form_style", {"form": p["form"], "style": p["style"]}))
193
- if p["form_variant"]:
194
- axes.append(("form_variant_style", {"form": p["form"], "variant": p["form_variant"], "style": p["style"]}))
195
- if p["task_audience"]:
196
- axes.append(("form_task", {"form": p["form"], "task_audience": p["task_audience"]}))
197
- if p["form_variant"]:
198
- axes.append(("form_variant_task", {"form": p["form"], "variant": p["form_variant"], "task_audience": p["task_audience"]}))
199
- if p["style"] and p["task_audience"]:
200
- axes.append(("form_style_task", {"form": p["form"], "style": p["style"],
201
- "task_audience": p["task_audience"]}))
202
- if p["form_variant"]:
203
- axes.append(("form_variant_style_task", {"form": p["form"], "variant": p["form_variant"],
204
- "style": p["style"], "task_audience": p["task_audience"]}))
205
- for axis, labels in axes:
206
- cid = candidate_id(axis, labels)
207
- member_sets[cid].add(p["product_id"])
208
- definitions[cid] = (axis, labels)
209
-
210
- candidates = []
211
- for cid, ids in member_sets.items():
212
- axis, labels = definitions[cid]
213
- members = [by_id[pid] for pid in ids]
214
- members.sort(key=lambda p: (-p["payment_people_displayed"], p["product_id"]))
215
- prices = [p["price_yuan"] for p in members]
216
- payments = [p["payment_people_displayed"] for p in members]
217
- shop_groups = defaultdict(list)
218
- for p in members:
219
- shop_groups[p["shop_name"]].append(p)
220
- shops = []
221
- for name, shop_members in shop_groups.items():
222
- full_line = by_shop[name]
223
- shops.append({
224
- "shop_name": name,
225
- "candidate_product_count": len(shop_members),
226
- "candidate_payment_people_sum_displayed": sum(p["payment_people_displayed"] for p in shop_members),
227
- "natural_product_count_in_export": len(full_line),
228
- "natural_form_counts": dict(Counter(p["form"] or "unconfirmed" for p in full_line)),
229
- "natural_price_band_counts": dict(Counter(str(p["price_band"]) for p in full_line)),
230
- "same_shop_ad_appearance_count": len(ads_by_shop.get(name, [])),
231
- "same_shop_ad_product_count": len({a["product_id"] for a in ads_by_shop.get(name, [])}),
232
- "candidate_product_ids": [p["product_id"] for p in shop_members],
233
- })
234
- shops.sort(key=lambda s: (-s["candidate_payment_people_sum_displayed"], s["shop_name"]))
235
- top = shops[0]
236
- payment_sum = sum(payments)
237
- root_counts = Counter((r["facet"], r["root"]) for p in members for r in p["title_roots"])
238
- parent_id = candidate_id("form", {"form": labels["form"]}) if axis not in ("form", "product_style") else None
239
- candidates.append({
240
- "candidate_id": cid, "axis": axis, "labels": labels,
241
- "parent_form_candidate_id": parent_id,
242
- "product_ids": sorted(ids),
243
- "product_count": len(members), "shop_count": len(shops),
244
- "displayed_payment_people": {
245
- "sum_across_products": payment_sum,
246
- "median_per_product": round(statistics.median(payments), 2),
247
- "p75_per_product": percentile(payments, .75),
248
- "p90_per_product": percentile(payments, .90),
249
- "highest_product_share_of_sum": round(max(payments) / payment_sum, 4) if payment_sum else None,
250
- "top_shop_share_of_sum": round(top["candidate_payment_people_sum_displayed"] / payment_sum, 4) if payment_sum else None,
251
- "sum_after_excluding_top_shop": payment_sum - top["candidate_payment_people_sum_displayed"],
252
- },
253
- "displayed_price_yuan": {"min": min(prices), "p25": percentile(prices, .25),
254
- "median": round(statistics.median(prices), 2),
255
- "p75": percentile(prices, .75), "max": max(prices)},
256
- "price_band_product_counts": dict(sorted(Counter(str(p["price_band"]) for p in members).items())),
257
- "natural_advertised_product_count_same_shop": sum(p["ad_appearances_same_shop"] > 0 for p in members),
258
- "ad_appearance_count_same_shop": sum(p["ad_appearances_same_shop"] for p in members),
259
- "ad_appearance_count_other_shop": sum(p["ad_appearances_other_shop"] for p in members),
260
- "title_roots_top": [{"facet": facet, "root": root, "product_count": count}
261
- for (facet, root), count in root_counts.most_common(12)],
262
- "listing_address_top": [{"address": address, "product_count": count}
263
- for address, count in Counter(p["listing_address"] for p in members).most_common(8)],
264
- "leading_products": [{"product_id": p["product_id"], "shop_name": p["shop_name"],
265
- "price_yuan": p["price_yuan"], "payment_people_displayed": p["payment_people_displayed"],
266
- "image_url": p["image_url"]} for p in members[:10]],
267
- "leading_shops": shops[:10],
268
- "physical_deltas_observed": list(dict.fromkeys(
269
- delta for p in members for delta in (p["style_product_delta"], p["task_product_delta"]) if delta))[:12],
270
- })
271
- candidate_axis_order = ("product_style", "form", "form_variant", "form_style", "form_task", "form_style_task",
272
- "form_variant_style", "form_variant_task", "form_variant_style_task")
273
- candidates.sort(key=lambda c: candidate_axis_order.index(c["axis"]) * 100000 - c["product_count"])
274
-
275
- output = {
276
- "schema_version": 1,
277
- "purpose": "descriptive evidence for user-reviewed opportunity cards; not an entry score",
278
- "sources": {**{name: {"sha256": digest(path), "file_name": Path(path).name}
279
- for name, path in paths.items()},
280
- "prototype_labels": {"sha256": digest(args.prototype_labels),
281
- "file_name": Path(args.prototype_labels).name}},
282
- "cleaned_workbook_sha256": next(iter(source_hashes.values())),
283
- "population": {
284
- "natural_products": len(products),
285
- "visual_observed": len(observed),
286
- "visual_tagged": sum(p["visual_status"] == "tagged" for p in products),
287
- "visual_uncertain": sum(p["visual_status"] == "uncertain" for p in products),
288
- "visual_not_observed": sum(p["visual_status"] == "not_observed" for p in products),
289
- "visual_product_style_classified": sum(bool(p["product_style_ids"]) for p in products),
290
- "advertising_appearances": len(ads["ad_appearance_records"]),
291
- "advertising_unique_products": len(ads_by_id),
292
- },
293
- "grain_notes": [
294
- "Each natural product ID appears once; candidate families overlap and must not be summed together.",
295
- "Displayed payment people have an unknown time window and are not units, GMV, conversion or unique buyers across products.",
296
- "Ad appearances are sampled positions, not spend or impressions; cross-shop ID matches are separate.",
297
- "Listing address is not a verified factory or dispatch warehouse.",
298
- "Task-audience labels indicate image-grounded use hypotheses, not actual buyer demographics.",
299
- ],
300
- "products": products,
301
- "candidates": candidates,
302
- }
303
- path = Path(args.output)
304
- path.parent.mkdir(parents=True, exist_ok=True)
305
- with path.open("x", encoding="utf-8") as stream:
306
- json.dump(output, stream, ensure_ascii=False, indent=2)
307
- stream.write("\n")
308
- print(json.dumps({"output": str(path.resolve()), "products": len(products),
309
- "candidates": len(candidates), "tagged": output["population"]["visual_tagged"]}, ensure_ascii=False))
310
-
311
-
312
- if __name__ == "__main__":
313
- parser = argparse.ArgumentParser(description=__doc__)
314
- parser.add_argument("--visual-labels", required=True)
315
- parser.add_argument("--prototype-labels", required=True,
316
- help="compiled product_style_labels.json for whole-product prototypes")
317
- parser.add_argument("--price-detail", required=True)
318
- parser.add_argument("--title-roots", required=True)
319
- parser.add_argument("--geography-detail", required=True)
320
- parser.add_argument("--advertiser-detail", required=True)
321
- parser.add_argument("--output", required=True)
322
- build(parser.parse_args())