@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,822 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Assemble a comprehensive report ViewModel from reviewed run artifacts.
3
-
4
- This adapter transforms evidence and interpretation; HTML is rendered only by
5
- the package's pinned report runtime.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import argparse
11
- import hashlib
12
- import json
13
- import math
14
- import re
15
- from collections import Counter
16
- from pathlib import Path
17
- from urllib.parse import parse_qs, urlsplit
18
-
19
- from openpyxl import load_workbook
20
-
21
-
22
- def read_json(path: Path):
23
- return json.loads(path.read_text(encoding="utf-8"))
24
-
25
-
26
- def source_path(manifest_path: Path, value: str) -> Path:
27
- path = Path(value)
28
- if not path.is_absolute():
29
- path = manifest_path.parent / path
30
- if not path.is_file():
31
- raise FileNotFoundError(path)
32
- return path
33
-
34
-
35
- def read_product_links(workbook_path: Path) -> dict[str, str]:
36
- """Preserve source-listing links at the unique natural product-ID grain."""
37
- workbook = load_workbook(workbook_path, read_only=True, data_only=True)
38
- try:
39
- sheet = workbook["自然位"]
40
- rows = sheet.values
41
- columns = {str(value): index for index, value in enumerate(next(rows))}
42
- if not {"商品ID", "商品链接"} <= columns.keys():
43
- raise ValueError("cleaned natural sheet requires 商品ID and 商品链接")
44
- links: dict[str, str] = {}
45
- for row in rows:
46
- pid = str(row[columns["商品ID"]] or "").strip()
47
- url = str(row[columns["商品链接"]] or "").strip()
48
- parsed = urlsplit(url)
49
- host = parsed.hostname or ""
50
- allowed_host = host in {"taobao.com", "tmall.com"} or host.endswith((".taobao.com", ".tmall.com"))
51
- if not pid or pid in links:
52
- raise ValueError(f"blank or duplicate natural product ID in link source: {pid!r}")
53
- if parsed.scheme != "https" or not allowed_host or parsed.username or parsed.password:
54
- raise ValueError(f"invalid marketplace URL for product {pid}")
55
- if parse_qs(parsed.query).get("id") != [pid]:
56
- raise ValueError(f"marketplace URL ID disagrees with product {pid}")
57
- links[pid] = url
58
- return links
59
- finally:
60
- workbook.close()
61
-
62
-
63
- def plain(text: object) -> str:
64
- value = str(text if text is not None else "")
65
- value = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", value)
66
- return value.replace("**", "").replace("__", "").replace("`", "").strip()
67
-
68
-
69
- def visual_group(product: dict) -> str:
70
- if product.get("form"):
71
- return product["form"]
72
- return "未识图" if product.get("visual_status") == "not_observed" else "待确认形态"
73
-
74
-
75
- def visual_state(status: str) -> str:
76
- return {"tagged": "分类明确", "uncertain": "待确认", "not_observed": "未识图(图片不可用)"}.get(status, "待确认")
77
-
78
-
79
- def table(title: str, columns: list[tuple[str, str]], rows: list[dict], meta: str = "") -> dict:
80
- return {
81
- "type": "table",
82
- "title": title,
83
- "meta": meta or f"{len(rows)} 行",
84
- "columns": [label for _, label in columns],
85
- "rows": [[row.get(key) for key, _ in columns] for row in rows],
86
- }
87
-
88
-
89
- def point_tone(label: str) -> tuple[str, str]:
90
- if any(word in label for word in ("启发", "行动", "下一步", "验证", "支持")):
91
- return label or "选款启发", "positive"
92
- if any(word in label for word in ("反证", "限制", "边界", "风险", "未知")):
93
- return label or "注意边界", "warning"
94
- if any(word in label for word in ("解读", "判断", "推断")):
95
- return label or "分析判断", "info"
96
- return label or "要点", "info"
97
-
98
-
99
- def point_items(value: str, label: str = "") -> list[dict]:
100
- """One source judgment becomes one numbered row, including its evidence and caveat."""
101
- raw = value.strip()
102
- content = plain(raw)
103
- if not content:
104
- return []
105
- marked = re.match(r"^\*\*([^*]{2,100})\*\*\s*(.*)$", raw, re.S)
106
- if marked:
107
- title = plain(marked.group(1)).rstrip("。::")
108
- body = plain(marked.group(2))
109
- else:
110
- lead = re.match(r"^(.{8,46}?[,。!?;:])", content)
111
- if lead:
112
- title = lead.group(1).strip()
113
- body = content[len(lead.group(1)):].strip()
114
- else:
115
- title = label if label and not label.startswith("第") else "研究判断"
116
- body = content
117
- semantic = title if any(word in title for word in ("启发", "解读", "边界", "风险", "验证", "布局")) else label
118
- if not semantic or semantic.startswith("第"):
119
- semantic = "结论"
120
- badge, tone = point_tone(semantic)
121
- if len(badge) > 12:
122
- badge = "选款启发" if tone == "positive" else "研究判断"
123
- return [{"title": title, "text": body, "label": badge, "tone": tone,
124
- "source_chunk": content}]
125
-
126
-
127
- def prose_block(value: str) -> dict:
128
- raw = value.strip()
129
- if len(plain(raw)) <= 65 and not raw.startswith("**"):
130
- return {"type": "text", "text": plain(raw)}
131
- return {"type": "actions", "items": point_items(raw)}
132
-
133
-
134
- def merge_adjacent_actions(items: list[dict]) -> list[dict]:
135
- merged: list[dict] = []
136
- for item in items:
137
- if item["type"] == "actions" and merged and merged[-1]["type"] == "actions":
138
- merged[-1]["items"].extend(item["items"])
139
- else:
140
- merged.append(item)
141
- return merged
142
-
143
-
144
- def md_blocks(markdown: str) -> list[dict]:
145
- """Keep the reviewed prose, lists and pipe tables in readable blocks."""
146
- lines = markdown.splitlines()
147
- blocks: list[dict] = []
148
- paragraph: list[str] = []
149
- bullet: list[str] = []
150
- bullet_ordered: bool | None = None
151
- section_title: str | None = None
152
- section_blocks: list[dict] = []
153
-
154
- def add(block: dict) -> None:
155
- (section_blocks if section_title else blocks).append(block)
156
-
157
- def flush_paragraph() -> None:
158
- if paragraph:
159
- add(prose_block(" ".join(paragraph)))
160
- paragraph.clear()
161
-
162
- def flush_bullet() -> None:
163
- nonlocal bullet_ordered
164
- if bullet:
165
- if bullet_ordered or any(len(plain(item)) > 65 for item in bullet):
166
- rows = []
167
- for index, item in enumerate(bullet, 1):
168
- rows.extend(point_items(item, "结论"))
169
- add({"type": "actions", "items": rows})
170
- else:
171
- add({"type": "list", "ordered": bool(bullet_ordered), "items": [plain(item) for item in bullet]})
172
- bullet.clear()
173
- bullet_ordered = None
174
-
175
- def flush_section() -> None:
176
- nonlocal section_title, section_blocks
177
- flush_paragraph()
178
- flush_bullet()
179
- if section_title:
180
- blocks.append({"type": "card", "title": section_title, "blocks": merge_adjacent_actions(section_blocks)})
181
- section_title, section_blocks = None, []
182
-
183
- i = 0
184
- while i < len(lines):
185
- line = lines[i].strip()
186
- if line.startswith("# "):
187
- flush_section()
188
- i += 1
189
- continue
190
- if line.startswith("## ") or line.startswith("### "):
191
- flush_section()
192
- section_title = plain(line.lstrip("# "))
193
- i += 1
194
- continue
195
- if line.startswith("|") and i + 1 < len(lines) and re.fullmatch(r"[|:\-\s]+", lines[i + 1].strip()):
196
- flush_paragraph()
197
- flush_bullet()
198
- heads = [plain(cell) for cell in line.strip("|").split("|")]
199
- i += 2
200
- rows = []
201
- while i < len(lines) and lines[i].strip().startswith("|"):
202
- cells = [plain(cell) for cell in lines[i].strip().strip("|").split("|")]
203
- rows.append({f"c{j}": cells[j] if j < len(cells) else "" for j in range(len(heads))})
204
- i += 1
205
- add(table("数据对照", [(f"c{j}", h) for j, h in enumerate(heads)], rows))
206
- continue
207
- bullet_match = re.match(r"^(\d+\.|[-*])\s+(.+)$", line)
208
- if bullet_match:
209
- flush_paragraph()
210
- ordered = bullet_match.group(1).endswith(".")
211
- if bullet and ordered != bullet_ordered:
212
- flush_bullet()
213
- bullet_ordered = ordered
214
- bullet.append(bullet_match.group(2))
215
- i += 1
216
- continue
217
- if not line:
218
- flush_paragraph()
219
- flush_bullet()
220
- else:
221
- if bullet:
222
- bullet[-1] += " " + line
223
- else:
224
- paragraph.append(line)
225
- i += 1
226
- flush_section()
227
- return merge_adjacent_actions(blocks)
228
-
229
-
230
- def conclusion(items: list[tuple[str, str, str, str]]) -> dict:
231
- return {"type": "conclusion", "title": "先看结论", "items": [
232
- {"label": label, "tone": tone, "segments": [
233
- {"text": lead, "emphasis": "strong", "tone": tone},
234
- {"text": rest, "emphasis": "italic" if tone == "warning" else "normal"},
235
- ]}
236
- for label, lead, rest, tone in items
237
- ]}
238
-
239
-
240
- def rounded(value, digits=1):
241
- if value is None:
242
- return "—"
243
- if isinstance(value, (int, float)):
244
- formatted = f"{value:,.{digits}f}"
245
- return formatted.rstrip("0").rstrip(".") if digits > 0 else formatted
246
- return str(value)
247
-
248
-
249
- def bound_interpretation(text: str, marker: str, source_sha256: str) -> str:
250
- match = re.match(rf"\A<!-- {re.escape(marker)}: ([0-9a-f]{{64}}) -->\s*\n", text)
251
- if not match or match.group(1) != source_sha256:
252
- raise ValueError(f"interpretation is not bound to the current {marker}")
253
- return text[match.end():].lstrip("\n")
254
-
255
-
256
- def check_report_bindings(loaded: dict, srcs: dict) -> None:
257
- visual_files = loaded["visual"]
258
- decision_files = loaded["decision"]
259
- for role in ("summary", "labels", "prototypes"):
260
- if role not in visual_files:
261
- raise ValueError(f"report requires visual.{role} from the current prototype classification")
262
- for role in ("evidence", "cards"):
263
- if role not in decision_files:
264
- raise ValueError(f"report requires decision.{role}")
265
- evidence = decision_files["evidence"]
266
- cards_doc = decision_files["cards"]
267
- prototype_source = evidence.get("sources", {}).get("prototype_labels", {})
268
- if prototype_source.get("sha256") != srcs["visual"]["labels"]["sha256"]:
269
- raise ValueError("report prototype labels do not match comparison evidence")
270
- if cards_doc.get("schema_version") != 2 or cards_doc.get("source_evidence_sha256") != srcs["decision"]["evidence"]["sha256"]:
271
- raise ValueError("report opportunity cards are not bound to current comparison evidence")
272
- candidates = {candidate["candidate_id"]: candidate for candidate in evidence["candidates"]}
273
- if not cards_doc.get("cards"):
274
- raise ValueError("report has no opportunity cards")
275
- for card in cards_doc["cards"]:
276
- candidate = candidates.get(card.get("candidate_id"))
277
- if not candidate or candidate.get("axis") != "product_style":
278
- raise ValueError("report opportunity card must reference a whole-product style prototype")
279
-
280
- labels = visual_files["labels"]
281
- prototypes = visual_files["prototypes"]
282
- if not labels or not prototypes:
283
- raise ValueError("report requires complete product-style labels and prototypes")
284
- label_by_id = {str(row["product_id"]): row for row in labels}
285
- if len(label_by_id) != len(labels):
286
- raise ValueError("duplicate product-style label ID")
287
- products = {str(row["product_id"]): row for row in evidence["products"]}
288
- observed_ids = {pid for pid, row in products.items() if row["visual_status"] != "not_observed"}
289
- if set(label_by_id) != observed_ids:
290
- raise ValueError("product-style label coverage disagrees with observed products")
291
- prototype_ids = [row["style_id"] for row in prototypes]
292
- if len(prototype_ids) != len(set(prototype_ids)):
293
- raise ValueError("duplicate product-style prototype ID")
294
- prototype_names = [row["name"] for row in prototypes]
295
- if len(prototype_names) != len(set(prototype_names)) or set(prototype_names) & {"边界/待确认", "未识图"}:
296
- raise ValueError("duplicate or reserved product-style prototype name")
297
- groups = {style_id: set() for style_id in prototype_ids}
298
- for pid, label in label_by_id.items():
299
- if label["style_ids"] != products[pid]["product_style_ids"] or label.get("boundary_reason") != products[pid].get("product_style_boundary"):
300
- raise ValueError(f"report product-style assignment disagrees for {pid}")
301
- for style_id in label["style_ids"]:
302
- if style_id not in groups:
303
- raise ValueError(f"unknown product-style prototype: {style_id}")
304
- groups[style_id].add(pid)
305
- for prototype in prototypes:
306
- actual = groups[prototype["style_id"]]
307
- if actual != set(map(str, prototype["product_ids"])) or len(actual) != prototype["product_count"]:
308
- raise ValueError(f"prototype summary membership disagrees: {prototype['style_id']}")
309
- if "interpretation" in visual_files:
310
- visual_files["interpretation"] = bound_interpretation(
311
- visual_files["interpretation"], "source_prototype_summary_sha256",
312
- srcs["visual"]["prototypes"]["sha256"])
313
- if "interpretation" in decision_files:
314
- decision_files["interpretation"] = bound_interpretation(
315
- decision_files["interpretation"], "source_evidence_sha256",
316
- srcs["decision"]["evidence"]["sha256"])
317
-
318
-
319
- def build(manifest_path: Path, output_path: Path) -> None:
320
- manifest = read_json(manifest_path)
321
- required = ["advertising", "title", "geography", "price", "visual", "decision"]
322
- for key in required:
323
- if key not in manifest["modules"]:
324
- raise ValueError(f"missing module: {key}")
325
- loaded: dict[str, dict] = {}
326
- srcs: dict[str, dict] = {}
327
- for key, files in manifest["modules"].items():
328
- loaded[key], srcs[key] = {}, {}
329
- for role, value in files.items():
330
- path = source_path(manifest_path, value)
331
- loaded[key][role] = path.read_text(encoding="utf-8") if path.suffix == ".md" else read_json(path)
332
- srcs[key][role] = {"name": path.name, "sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
333
- check_report_bindings(loaded, srcs)
334
-
335
- ad = loaded["advertising"]["detail"]
336
- roots = loaded["title"]["summary"]
337
- geo = loaded["geography"]["summary"]
338
- price = loaded["price"]["summary"]
339
- visual = loaded["visual"]["summary"]
340
- prototypes = loaded["visual"]["prototypes"]
341
- evidence = loaded["decision"]["evidence"]
342
- cards = loaded["decision"]["cards"]["cards"]
343
- pop = evidence["population"]
344
- products = evidence["products"]
345
- candidates = evidence["candidates"]
346
- sample = manifest.get("sample", {})
347
- if len(products) != pop["natural_products"]:
348
- raise ValueError("natural product population mismatch")
349
- if ad["counts"]["natural_products"] != len(products):
350
- raise ValueError("advertising natural population mismatch")
351
- if sum(b["product_count"] for b in price["bands"]) != len(products):
352
- raise ValueError("price-band population mismatch")
353
- if visual["scope"]["selected"] != len(products):
354
- raise ValueError("visual population mismatch")
355
- if visual["scope"]["ready"] != visual["reviewed_products"] or pop["visual_observed"] != visual["reviewed_products"]:
356
- raise ValueError("ready-image observation coverage mismatch")
357
- if visual["scope"]["image_unavailable"] != pop["visual_not_observed"]:
358
- raise ValueError("unobserved products must match unavailable images")
359
- unavailable_reasons = visual.get("image_unavailable_by_reason", {})
360
- if sum(unavailable_reasons.values()) != pop["visual_not_observed"]:
361
- raise ValueError("unavailable-image reasons do not reconcile")
362
- if set(unavailable_reasons) - {"too_large"}:
363
- raise ValueError("non-size image failures require resolution before the full report")
364
- if len({p["product_id"] for p in products}) != len(products):
365
- raise ValueError("duplicate natural product ID")
366
- expected_workbook_hash = manifest.get("cleaned_workbook_sha256")
367
- if expected_workbook_hash and evidence["cleaned_workbook_sha256"] != expected_workbook_hash:
368
- raise ValueError("evidence workbook hash mismatch")
369
- if manifest.get("cleaned_workbook"):
370
- workbook_path = source_path(manifest_path, manifest["cleaned_workbook"])
371
- workbook_hash = hashlib.sha256(workbook_path.read_bytes()).hexdigest()
372
- if workbook_hash != evidence["cleaned_workbook_sha256"]:
373
- raise ValueError("cleaned workbook file hash mismatch")
374
- product_links = read_product_links(workbook_path)
375
- srcs["input"] = {"cleaned_workbook": {"name": workbook_path.name, "sha256": workbook_hash}}
376
- else:
377
- product_links = {p["product_id"]: p["item_url"] for p in products if p.get("item_url")}
378
- if set(product_links) != {p["product_id"] for p in products}:
379
- raise ValueError("product-link coverage mismatch; provide cleaned_workbook in report manifest")
380
-
381
- candidate_by_id = {c["candidate_id"]: c for c in candidates}
382
- product_by_id = {p["product_id"]: p for p in products}
383
- for card in cards:
384
- if card["candidate_id"] not in candidate_by_id:
385
- raise ValueError(f"unresolved candidate: {card['candidate_id']}")
386
-
387
- views: list[dict] = []
388
-
389
- def view(id_: str, label: str, title: str, subtitle: str, blocks: list[dict]) -> None:
390
- views.append({"id": id_, "label": label, "title": title, "subtitle": subtitle, "blocks": blocks})
391
-
392
- card_sorted = sorted(cards, key=lambda c: c.get("research_order", {}).get("position", 999))
393
- intro = conclusion([
394
- ("先研究什么", plain(card_sorted[0]["definition"].split(":")[0]), "。先做同配置、评价和店铺因素核验。", "positive"),
395
- ("如何理解顺序", "这是核验顺序,", "不是入场排名;每张卡并列展示支持、反证和改变判断的条件。", "info"),
396
- ("最重要的边界", "付款人数统计周期未知,", "搜索快照不提供市场增长、利润或广告花费。", "warning"),
397
- ])
398
- overview_rows = []
399
- for card in card_sorted:
400
- group = candidate_by_id[card["candidate_id"]]
401
- overview_rows.append({
402
- "order": card["research_order"]["position"],
403
- "proposal": card["definition"].split(":")[0],
404
- "products": group["product_count"],
405
- "shops": group["shop_count"],
406
- "payment": rounded(group["displayed_payment_people"]["median_per_product"]),
407
- "price": rounded(group["displayed_price_yuan"]["median"], 2),
408
- "why": card["research_order"]["reason"],
409
- "candidate": card["candidate_id"],
410
- })
411
- view("overview", "一页读懂", manifest["title"], "从搜索样本走向值得核验的商品方案", [
412
- intro,
413
- {"type": "metrics", "columns": 4, "items": [
414
- {"label": "自然位唯一商品", "value": str(pop["natural_products"])},
415
- {"label": "广告位出现", "value": str(pop["advertising_appearances"])},
416
- {"label": "视觉已打标", "value": str(pop["visual_tagged"])},
417
- {"label": "视觉待确认", "value": str(pop["visual_uncertain"]), "tone": "warning"},
418
- ]},
419
- table("首批研究方案", [("order", "顺序"), ("proposal", "商品方案"), ("products", "商品"), ("shops", "店铺"), ("payment", "展示付款人数中位数"), ("price", "展示价格中位数/元"), ("why", "为何先查"), ("candidate", "证据组ID")], overview_rows),
420
- {"type": "actions", "items": [
421
- {"title": "读市场结构", "text": "广告、标题、地址、价格和视觉章节保留各阶段完整解读与证据。", "label": "分析", "tone": "info"},
422
- {"title": "查具体方案", "text": "每张机会判断卡都有支持、反证、代表商品及改变判断的下一步。", "label": "判断", "tone": "positive"},
423
- {"title": "回到商品明细", "text": "全量自然位数据与候选组合供按ID核对;独立JSON保存更细证据。", "label": "追溯", "tone": "warning"},
424
- ]},
425
- ])
426
-
427
- view("sample", "样本与清洗", "样本与口径", "自然位与广告位在不同统计粒度上分析", [
428
- conclusion([
429
- ("自然位", f"{pop['natural_products']:,} 个唯一商品 ID。", "同 ID 按源表顺序保留首条。", "info"),
430
- ("广告位", f"{pop['advertising_appearances']:,} 条出现记录、{pop['advertising_unique_products']:,} 个商品 ID。", "重复出现保留,用于研究谁在广告位露出。", "warning"),
431
- ]),
432
- {"type": "text", "paragraphs": [
433
- (f"源表 {sample['source_rows']:,} 行;自然位原始 {sample['natural_before_dedup']:,} 行,"
434
- f"去重排除 {sample['natural_deduplicated_out']:,} 行;广告位 {pop['advertising_appearances']:,} 行。自然位与广告位分开统计。")
435
- if {"source_rows", "natural_before_dedup", "natural_deduplicated_out"} <= sample.keys()
436
- else f"自然位唯一商品 {pop['natural_products']:,} 件;广告位出现 {pop['advertising_appearances']:,} 条,按各自粒度统计。",
437
- "自然位付款人数仅是列表展示值,统计期未知;广告位出现次数不是曝光量、投放成本或转化。图片标签来自本次视觉观察,任务标签代表可见使用线索而非真实买家画像。",
438
- f"清洗工作簿 SHA-256:{evidence['cleaned_workbook_sha256']}。分析中所有自然位产品按这份清洗结果和唯一商品 ID 对齐。",
439
- ]},
440
- ])
441
-
442
- ad_shops = [{
443
- "shop": s["shop_name"], "ad_products": s["ad_product_count"], "appearances": s["ad_appearance_count"],
444
- "natural_products": s["natural_product_count_by_exact_shop_name"],
445
- "ad_payment": rounded(s["natural_advertised_product_payment_people_sum_by_same_shop_name"], 0),
446
- "nonad_payment": rounded(s["natural_unadvertised_product_payment_people_sum_by_same_shop_name"], 0),
447
- "ad_only": s["ad_only_products"],
448
- } for s in ad["advertiser_shops"]]
449
- ad_products = [{
450
- "id": x["product_id"], "shop": x["shop_name"], "title": x["title"],
451
- "price": rounded(x["ad_price_first_appearance"], 2), "appearances": x["ad_appearances"],
452
- "natural_payment": rounded(x["natural_payment_people"], 0),
453
- "in_natural": "是" if x["in_natural"] else "否/未知付款",
454
- } for x in ad["ad_products"]]
455
- view("advertising", "广告与店铺", "谁在广告位出现", "案例解读与商品布局证据", [
456
- conclusion([
457
- ("店铺策略不一", "广告商品与自然位强品的关系不同。", "需逐店看商品线,而非用广告频次挑款。", "positive"),
458
- ("广告口径", "出现次数不等于花费。", "广告商品缺少自然位记录时,付款情况是未知。", "warning"),
459
- ]),
460
- *md_blocks(loaded["advertising"]["interpretation"]),
461
- table("全部广告店铺布局", [("shop", "店铺"), ("ad_products", "广告商品ID"), ("appearances", "出现次数"), ("natural_products", "自然位商品"), ("ad_payment", "同店广告商品可见付款之和"), ("nonad_payment", "同店非广告商品可见付款之和"), ("ad_only", "仅广告位商品")], ad_shops),
462
- table("广告商品明细", [("id", "商品ID"), ("shop", "店铺"), ("title", "标题"), ("price", "广告现价/元"), ("appearances", "出现次数"), ("natural_payment", "自然位展示付款"), ("in_natural", "同ID见于自然位")], ad_products),
463
- ])
464
-
465
- root_rows = [{"root": r["root"], "facet": r["facet"], "products": r["product_count"],
466
- "shops": r["shop_count"], "price": rounded(r["price_median"], 2),
467
- "payment": rounded(r["payment_people_median"], 0), "high": r["high_payment_product_count"]}
468
- for r in roots["roots"]]
469
- shop_rows = [{"shop": s["shop_name"], "products": s["natural_product_count"],
470
- "payment_sum": rounded(s["displayed_payment_people_sum_across_visible_products"], 0),
471
- "distinctive_roots": "、".join(x["root"] for x in s["top_distinctive_roots"][:5])}
472
- for s in roots["shops"]]
473
- top_root = max(roots["roots"], key=lambda r: r["product_count"])
474
- view("title_roots", "标题与词根", "标题说了什么", "商家表达的词根结构和店铺布局", [
475
- conclusion([
476
- ("高覆盖词根", f"“{top_root['root']}”覆盖 {top_root['product_count']:,} 件商品。", "高频表述需与实物形态及店铺分布一起判断。", "info"),
477
- ("核验边界", "标题词是商家表述。", "材料、功效和真实商品形态须结合图片、详情与实物证据。", "warning"),
478
- ]),
479
- *md_blocks(loaded["title"]["interpretation"]),
480
- table("全部标题词根", [("root", "词根"), ("facet", "属性"), ("products", "商品数"), ("shops", "店铺数"), ("price", "价格中位数/元"), ("payment", "展示付款中位数"), ("high", "高付款商品数")], root_rows),
481
- table("店铺词根布局", [("shop", "店铺"), ("products", "自然位商品"), ("payment_sum", "展示付款之和"), ("distinctive_roots", "突出词根")], shop_rows),
482
- ])
483
-
484
- city_rows = [{"region": r["region"], "products": r["product_count"], "shops": r["shop_count"],
485
- "price": rounded(r["price_median"], 2), "payment": rounded(r["payment_people_median"], 0),
486
- "high": r["high_payment_product_count"], "top_share": f"{100*r['top_shop_product_share']:.1f}%"}
487
- for r in geo["city_regions"]]
488
- focus_set = {tuple(pair) for pair in manifest.get("focus_region_roots", [])}
489
- focus_rows = [{"region": r["region"], "root": r["root"], "products": r["product_count"],
490
- "shops": r["shop_count"], "price": rounded(r["price_median"], 2),
491
- "payment": rounded(r["payment_people_median"], 0), "high": r["high_payment_product_count"],
492
- "relative": rounded(r["location_quotient"], 2)}
493
- for r in geo["root_regions"] if (r["region"], r["root"]) in focus_set]
494
- view("geography", "地址与区域", "列表地址的区域结构", "观察商家和商品聚集,不推定工厂所在地", [
495
- conclusion([
496
- ("地区分布", f"样本出现 {len(geo['city_regions'])} 个列表地址值。", "可作为后续商家和商品群核验线索。", "info"),
497
- ("地址边界", "列表地址不等于工厂所在地。", "供应链结论仍需独立核验。", "warning"),
498
- ]),
499
- *md_blocks(loaded["geography"]["interpretation"]),
500
- table("全部地址区域", [("region", "列表地址"), ("products", "商品数"), ("shops", "店铺数"), ("price", "价格中位数/元"), ("payment", "展示付款中位数"), ("high", "高付款商品"), ("top_share", "最大店商品占比")], city_rows),
501
- *( [table("文中重点地区 × 词根证据", [("region", "地址"), ("root", "标题词根"), ("products", "商品数"), ("shops", "店铺数"), ("price", "价格中位数/元"), ("payment", "展示付款中位数"), ("high", "高付款商品"), ("relative", "相对覆盖倍数")], focus_rows)] if focus_rows else [] ),
502
- ])
503
-
504
- band_rows = []
505
- for b in price["bands"]:
506
- interval = f"{b['lower_inclusive']:.2f}—{'<'+format(b['upper_exclusive'], '.2f') if b['upper_exclusive'] is not None else format(b['price_max'], '.2f')}"
507
- band_rows.append({"band": b["band"], "range": interval, "products": b["product_count"],
508
- "share": f"{100*b['product_share']:.1f}%", "shops": b["shop_count"],
509
- "payment": rounded(b["payment_people_median"], 0), "ads": b["ad_appearance_count"],
510
- "ad_ids": b["ad_unique_product_count"]})
511
- largest_band = max(price["bands"], key=lambda b: b["product_count"])
512
- ad_band = max(price["bands"], key=lambda b: b["ad_appearance_count"])
513
- def band_range(b):
514
- return f"{b['lower_inclusive']:.2f}—{b['upper_exclusive']:.2f} 元" if b["upper_exclusive"] is not None else f"{b['lower_inclusive']:.2f} 元及以上"
515
- view("price", "价格带", f"本样本的 {len(price['bands'])} 个价格群", "边界来自样本拟合,不是预设固定区间", [
516
- conclusion([
517
- ("供给中心", f"{band_range(largest_band)}有 {largest_band['product_count']} 件商品。", "这是样本中商品最多的一档,不等于进入建议。", "info"),
518
- ("广告集中", f"{band_range(ad_band)}出现 {ad_band['ad_appearance_count']} 次广告位。", "出现次数不能推算预算或投放效率。", "warning"),
519
- ]),
520
- *md_blocks(loaded["price"]["interpretation"]),
521
- table("样本自然价格带", [("band", "档"), ("range", "展示现价/元"), ("products", "自然位商品"), ("share", "占比"), ("shops", "店铺"), ("payment", "展示付款中位数"), ("ads", "广告出现"), ("ad_ids", "广告商品ID")], band_rows),
522
- ])
523
-
524
- # This is an evidence-review order, not a predicted profit or entry score.
525
- payment_values = sorted(max(0.0, float(p.get("payment_people_displayed") or 0)) for p in products)
526
- p99 = payment_values[min(len(payment_values) - 1, math.ceil(len(payment_values) * 0.99) - 1)]
527
- payment_scale = math.log1p(p99) if p99 > 0 else 1.0
528
- band_counts = Counter(p.get("price_band") for p in products)
529
- largest_band_count = max(band_counts.values())
530
-
531
- def research_score(product: dict) -> float:
532
- payment = max(0.0, float(product.get("payment_people_displayed") or 0))
533
- payment_part = min(1.0, math.log1p(payment) / payment_scale)
534
- band_part = band_counts[product.get("price_band")] / largest_band_count
535
- visual_part = 1.0 if product.get("product_style_ids") else 0.0
536
- return round(60 * payment_part + 25 * band_part + 15 * visual_part, 1)
537
-
538
- axes = visual["axes"]
539
- top_forms = [(item["name"], item["product_count"]) for item in sorted(prototypes, key=lambda x: -x["product_count"])[:2]]
540
- axis_blocks = []
541
- for key, title in [("form", "产品主形态"), ("style", "产品风格"), ("audience", "可见使用任务")]:
542
- rows = [{"label": label, "products": count, "examples": "、".join(axes[key]["examples"].get(label, []))}
543
- for label, count in axes[key]["canonical_counts"].items()]
544
- axis_blocks.append(table(title, [("label", "标签"), ("products", "商品数"), ("examples", "代表商品ID")], rows))
545
- variant_rows = [{"family": item["family"], "variant": item["variant"], "products": item["products"]}
546
- for item in axes["form"].get("variant_counts", [])]
547
- if variant_rows:
548
- axis_blocks.insert(1, table("主形态下的子形态与配置", [("family", "主形态"), ("variant", "子形态/配置"),
549
- ("products", "商品数")], variant_rows))
550
- form_groups: dict[str, list[dict]] = {}
551
- for prototype in prototypes:
552
- ids = prototype["product_ids"]
553
- if len(ids) != prototype["product_count"] or any(pid not in product_by_id for pid in ids):
554
- raise ValueError(f"invalid product-style members: {prototype['style_id']}")
555
- form_groups[prototype["name"]] = [product_by_id[pid] for pid in ids]
556
- boundary = [product for product in products if product.get("product_style_boundary")]
557
- unavailable = [product for product in products if product.get("visual_status") == "not_observed"]
558
- if boundary:
559
- form_groups["边界/待确认"] = boundary
560
- if unavailable:
561
- form_groups["未识图"] = unavailable
562
- if {p["product_id"] for members in form_groups.values() for p in members} != set(product_by_id):
563
- raise ValueError("product-style gallery does not cover natural products")
564
- prototype_by_name = {prototype["name"]: prototype for prototype in prototypes}
565
- form_tabs = []
566
- for index, (form, members) in enumerate(sorted(form_groups.items(), key=lambda pair: (-len(pair[1]), pair[0])), 1):
567
- gallery_items = []
568
- for product in members:
569
- score = research_score(product)
570
- gallery_items.append({
571
- "image_url": product.get("image_url"), "alt": product["title"],
572
- "item_url": product_links[product["product_id"]],
573
- "title": product["title"], "price": f"¥{rounded(product.get('price_yuan'), 2)}",
574
- "badge": f"研究优先分 {score:.1f}", "tone": "info" if product.get("product_style_ids") else "warning",
575
- "copy_value": product["product_id"], "copy_label": "复制商品ID",
576
- "facts": [
577
- {"label": "商品ID", "value": product["product_id"]},
578
- {"label": "展示付款人数", "value": rounded(product.get("payment_people_displayed"), 0)},
579
- {"label": "店铺", "value": product.get("shop_name") or "—"},
580
- {"label": "产品款式 / 子形态",
581
- "value": f"{form} / {product.get('form_variant') or '未细分'}"},
582
- {"label": "风格 / 配置", "value": f"{product.get('style') or '待确认'} / {'、'.join(product.get('form_features') or []) or '—'}"},
583
- {"label": "可见使用任务", "value": product.get("task_audience") or "未见明确任务"},
584
- {"label": "价格档 / 列表地址", "value": f"{product.get('price_band') or '—'} / {product.get('listing_address') or '—'}"},
585
- {"label": "视觉状态", "value": visual_state(product.get("visual_status"))},
586
- ],
587
- "evidence": product.get("visible_evidence") or ("原图超过读取上限,未作视觉判断" if product.get("visual_status") == "not_observed" else "视觉证据待确认"),
588
- "sort_values": {"price": product.get("price_yuan"),
589
- "payment": product.get("payment_people_displayed"), "score": score},
590
- })
591
- prototype = prototype_by_name.get(form)
592
- group_explanation = (
593
- f"{form}:{prototype['definition']}。选款差异:{prototype['selection_difference']}。"
594
- f"分类边界:{prototype['boundary']}。" if prototype else f"{form}:保留单独审阅的边界商品。"
595
- )
596
- form_tabs.append({"id": f"form-{index}", "label": form, "badge": str(len(members)), "blocks": [
597
- {"type": "callout", "text": f"{group_explanation}本组 {len(members)} 件自然位商品;同图多款时菜单可能重叠。每页 50 件。"},
598
- {"type": "products", "columns": 3, "page_size": 50,
599
- "sort_options": [
600
- {"key": "score", "label": "研究优先分:高到低", "direction": "desc"},
601
- {"key": "payment", "label": "展示付款人数:高到低", "direction": "desc"},
602
- {"key": "payment", "label": "展示付款人数:低到高", "direction": "asc"},
603
- {"key": "price", "label": "现价:低到高", "direction": "asc"},
604
- {"key": "price", "label": "现价:高到低", "direction": "desc"},
605
- ], "items": gallery_items},
606
- ]})
607
- view("visual", "商品形态", "图片显示了哪些商品方案", "完整产品款式原型,加上结构、风格与可见使用任务", [
608
- conclusion([
609
- ("形态结构", "、".join(f"{name} {count} 件" for name, count in top_forms) + "。", "同一搜索词下存在多种实物形态。", "info"),
610
- ("分类边界", f"{pop['visual_product_style_classified']:,} 件进入产品款式原型,{pop['visual_observed'] - pop['visual_product_style_classified']:,} 件为边界样本,{pop['visual_not_observed']:,} 件未识图。", "款式归属可以重叠;未识图商品不参与视觉形态聚合。", "warning"),
611
- ]),
612
- *[prose_block(value) for value in [
613
- (f"本章以完整实物款式原型组织图片:{len(prototypes)} 个原型,"
614
- f"{pop['visual_product_style_classified']:,} 件可读商品获得款式归属;"
615
- f"{pop['visual_observed'] - pop['visual_product_style_classified']:,} 件为边界样本,"
616
- f"{pop['visual_not_observed']:,} 件未识图。"),
617
- "每个款式代表一种可独立比较和开发的实物方案;主形态、局部配置、风格与可见使用任务保留为辅助证据。一个商品可展示多种实物,款式组数量不能相加。",
618
- "分类只描述图片可见的供给形态。是否值得白牌深入研究,需要继续核查展示付款、价格、店铺布局与广告证据;材质、真实人群和利润仍待独立验证。",
619
- ]],
620
- *(md_blocks(loaded["visual"]["interpretation"]) if "interpretation" in loaded["visual"] else [
621
- {"type": "alert", "tone": "warning", "title": "解读状态", "text": "本次全量视觉阶段只有标签汇总,以上文字是依据汇总所作的报告解读,尚未作为独立阶段结论审核。"}
622
- ]),
623
- *axis_blocks,
624
- {"type": "card", "title": "商品级研究优先分:公式与边界", "blocks": [
625
- {"type": "actions", "items": [
626
- {"title": "评分公式", "text": "研究优先分 = 60 × min[1, ln(1+该商品展示付款人数) / ln(1+样本付款人数P99)] + 25 × (该商品价格档自然位商品数 / 样本最大价格档商品数) + 15 × 款式归属明确性(已归属=1,边界/未识图=0)。结果保留 1 位小数。", "label": "可复算", "tone": "info"},
627
- {"title": "分数含义", "text": "高分表示当前搜索快照中有更多可观察的付款记录、所在价格档有更多可比商品、图片分类更明确,因此适合优先查看和核验。它不等于产品潜力、利润率或白牌可进入性。", "label": "研究顺序", "tone": "positive"},
628
- {"title": "使用边界", "text": "展示付款人数的统计周期未知;价格档商品多代表可见供给多,不证明需求更大;视觉明确性只衡量分类证据。评分没有纳入真实销量、利润、竞争成本、评价与供应链,跨搜索词或跨期不可直接比较。", "label": "注意边界", "tone": "warning"},
629
- ]},
630
- {"type": "text", "text": f"本样本付款人数 P99={rounded(p99, 0)};最大价格档有 {largest_band_count} 件自然位商品。"},
631
- ]},
632
- {"type": "tabs", "title": "全量视觉商品图库|按产品款式查看", "tabs": form_tabs},
633
- ])
634
-
635
- group_rows = []
636
- for c in candidates:
637
- labels = c["labels"]
638
- group_rows.append({
639
- "id": c["candidate_id"], "grain": c["axis"],
640
- "prototype": next((p["name"] for p in prototypes if p["style_id"] == labels.get("product_style_id")), "—"),
641
- "form": labels.get("form", "—"),
642
- "variant": labels.get("variant", "—"),
643
- "style": labels.get("style", "—"), "task": labels.get("task_audience", labels.get("audience", "—")),
644
- "products": c["product_count"], "shops": c["shop_count"],
645
- "payment": rounded(c["displayed_payment_people"]["median_per_product"], 0),
646
- "price": rounded(c["displayed_price_yuan"]["median"], 2),
647
- "ad": c["natural_advertised_product_count_same_shop"],
648
- })
649
- view("candidates", "组合对比", "把前面证据合到同一商品方案", f"{len(candidates)} 个可见候选组合,分层归类且彼此可能重叠", [
650
- conclusion([
651
- ("比较单位", "先比较完整产品款式原型,再看相近结构、配置与使用任务。", "价格、店铺和广告因素会改变表面上的付款差异。", "info"),
652
- ("组合口径", "产品款式与辅助维度可能互相重叠。", "这些行不能相加为一个市场总量。", "warning"),
653
- ]),
654
- table("全部候选组合", [("id", "证据组ID"), ("grain", "组合层级"), ("prototype", "产品款式原型"), ("form", "主形态"), ("variant", "子形态/配置"), ("style", "风格"), ("task", "使用任务"), ("products", "商品"), ("shops", "店铺"), ("payment", "展示付款中位数"), ("price", "价格中位数/元"), ("ad", "同店广告重合商品")], group_rows),
655
- ])
656
-
657
- assets = {}
658
- tabs = []
659
- for card in card_sorted:
660
- group = candidate_by_id[card["candidate_id"]]
661
- tab_blocks: list[dict] = [
662
- {"type": "callout", "text": card["definition"]},
663
- {"type": "metrics", "columns": 4, "items": [
664
- {"label": "自然位商品", "value": str(group["product_count"])},
665
- {"label": "店铺", "value": str(group["shop_count"])},
666
- {"label": "展示付款中位数", "value": rounded(group["displayed_payment_people"]["median_per_product"], 0)},
667
- {"label": "展示价格中位数/元", "value": rounded(group["displayed_price_yuan"]["median"], 2)},
668
- ]},
669
- {"type": "card", "title": "当前解读", "blocks": [prose_block(card["interpretation"])]},
670
- prose_block(card["scope"]),
671
- prose_block("父组/可比组:" + card["parent_comparison"]),
672
- ]
673
- for key, title, tone in [("observations", "观察到的数据", "info"),
674
- ("supporting_evidence", "支持继续研究", "positive"),
675
- ("counterevidence", "反证与其他解释", "warning")]:
676
- rows = []
677
- reference_rows = []
678
- for claim_index, item in enumerate(card[key], 1):
679
- label = f"{title} {claim_index}"
680
- for row in point_items(item["claim"], title):
681
- row["label"] = label
682
- row["tone"] = tone
683
- rows.append(row)
684
- reference_rows.append({"claim": label, "refs": "、".join(item["refs"])})
685
- tab_blocks.append({"type": "card", "title": title, "blocks": [
686
- {"type": "actions", "items": rows},
687
- table("对应证据字段与商品ID", [("claim", "主张"), ("refs", "可核对证据")], reference_rows),
688
- ]})
689
- tab_blocks.append({"type": "card", "title": "尚未知晓", "blocks": [{"type": "list", "items": card["unknowns"]}]})
690
- tab_blocks.append({"type": "actions", "items": [
691
- {"title": item["question"], "text": "改变判断的条件:" + item["changes_assessment_if"], "label": "下一步", "tone": "info"}
692
- for item in card["next_checks"]
693
- ]})
694
- band_counts = group.get("price_band_product_counts", {})
695
- if band_counts:
696
- tab_blocks.append(table("本方案的价格带分布", [("band", "价格档"), ("count", "自然位商品")],
697
- [{"band": str(band), "count": count} for band, count in sorted(band_counts.items(), key=lambda x: str(x[0]))]))
698
- shop_rows = []
699
- for shop in group.get("leading_shops", [])[:12]:
700
- shop_rows.append({
701
- "shop": shop["shop_name"], "candidate_products": shop["candidate_product_count"],
702
- "candidate_payment": rounded(shop["candidate_payment_people_sum_displayed"], 0),
703
- "natural_products": shop["natural_product_count_in_export"],
704
- "forms": "、".join(f"{name} {count}" for name, count in shop.get("natural_form_counts", {}).items()),
705
- "ad_products": shop.get("same_shop_ad_product_count", 0),
706
- "ad_appearances": shop.get("same_shop_ad_appearance_count", 0),
707
- })
708
- if shop_rows:
709
- tab_blocks.append(table("典型店铺的商品线与广告关联", [
710
- ("shop", "店铺"), ("candidate_products", "本方案商品"),
711
- ("candidate_payment", "本方案展示付款之和"), ("natural_products", "店内自然位商品"),
712
- ("forms", "店内形态布局"), ("ad_products", "同店广告商品ID"),
713
- ("ad_appearances", "广告位出现"),
714
- ], shop_rows))
715
- refs = [ref for field in ("observations", "supporting_evidence", "counterevidence")
716
- for item in card[field] for ref in item["refs"] if re.fullmatch(r"\d+", ref)]
717
- ids = list(dict.fromkeys(refs))
718
- if not ids:
719
- ids = group["product_ids"][:3]
720
- product_items = []
721
- for pid in ids[:5]:
722
- p = product_by_id.get(pid)
723
- if not p:
724
- continue
725
- asset_id = f"product-{pid}"
726
- image_path = Path(p.get("image_path") or "")
727
- if image_path.is_file():
728
- assets[asset_id] = {"path": str(image_path), "alt": p["title"], "role": "representative-product", "subject_id": pid, "evidence_level": "direct"}
729
- product_items.append({"asset_id": asset_id if asset_id in assets else None,
730
- "title": p["title"], "price": f"¥{rounded(p['price_yuan'], 2)}",
731
- "badge": p.get("shop_name", ""), "copy_value": pid, "copy_label": "复制商品ID",
732
- "facts": [{"label": "商品ID", "value": pid},
733
- {"label": "展示付款人数", "value": rounded(p.get("payment_people_displayed"), 0)}]})
734
- if product_items:
735
- tab_blocks.append({"type": "card", "title": "代表商品与证据ID", "blocks": [{"type": "products", "columns": 3, "items": product_items}]})
736
- tabs.append({"id": card["candidate_id"], "label": card["definition"].split(":")[0],
737
- "badge": str(card["research_order"]["position"]), "blocks": tab_blocks})
738
- if "interpretation" in loaded["decision"]:
739
- tabs.append({"id": "narrative", "label": "完整文字结论", "blocks":
740
- md_blocks(loaded["decision"]["interpretation"])})
741
- view("opportunities", "机会判断卡", "哪些方案值得继续拆解", "支持和反证并列,下一步用新证据改变判断", [
742
- {"type": "tabs", "title": "首批研究方案", "tabs": tabs},
743
- ])
744
-
745
- product_rows = [{
746
- "id": p["product_id"], "title": p["title"], "shop": p["shop_name"],
747
- "prototype": "、".join(proto["name"] for proto in prototypes if proto["style_id"] in p["product_style_ids"]) or "边界/未识图",
748
- "form": p.get("form") or "待确认", "variant": p.get("form_variant") or "—",
749
- "style": p.get("style") or "待确认",
750
- "task": p.get("task_audience") or "未见明确任务", "price": rounded(p.get("price_yuan"), 2),
751
- "band": p.get("price_band") or "—", "payment": rounded(p.get("payment_people_displayed"), 0),
752
- "address": p.get("listing_address") or "—", "ad": p.get("ad_appearances_same_shop", 0),
753
- "visual_status": p["visual_status"],
754
- } for p in products]
755
- view("evidence", "全量证据", "自然位商品与核验入口", "以下数据按商品ID唯一;页面搜索可定位商品、店铺及标签", [
756
- {"type": "alert", "tone": "warning", "title": "数据边界", "text": "展示付款人数统计期未知;展示价格不是经核实的实际SKU成交价;任务标签不证明买家身份。"},
757
- table("全部自然位商品", [("id", "商品ID"), ("title", "标题"), ("shop", "店铺"), ("prototype", "产品款式原型"),
758
- ("form", "主形态"), ("variant", "子形态/配置"), ("style", "风格"), ("task", "可见任务"),
759
- ("price", "现价/元"), ("band", "价格档"), ("payment", "展示付款人数"),
760
- ("address", "列表地址"), ("ad", "同店广告出现"), ("visual_status", "视觉状态")], product_rows),
761
- ])
762
-
763
- source_rows = [{"module": key, "role": role, "name": info["name"], "sha256": info["sha256"]}
764
- for key, roles in srcs.items() for role, info in roles.items()]
765
- view("method", "方法与边界", "怎样读这份报告", "样本、指标、源文件和未解问题", [
766
- conclusion([
767
- ("观察与判断", "商品、店铺和图片标签是样本证据。", "进入机会仍需评价、SKU、成本、供应商和利润核验。", "info"),
768
- ("当前状态", "这是一份搜索快照研究报告。", "结论随新证据修订,用户决定后续研究方向。", "warning"),
769
- ]),
770
- {"type": "list", "ordered": True, "items": [
771
- "广告位按出现记录与唯一商品ID分开计数;出现次数不是预算、曝光或转化。",
772
- "标题词根属于商家页面表述,可交叉但不可相加;材料真实性需要商品详情和实物复核。",
773
- "地址是搜索列表字段,不能直接当作工厂、仓库或产业带。",
774
- "价格带由当前样本现价经对数 Jenks 拟合,跨搜索词需重新计算。",
775
- f"视觉任务标签描述图片支持的器型/用途,不证明实际购买者。{pop['visual_uncertain']} 件边界商品不进入候选聚合。",
776
- "这是一张搜索结果快照,不提供市场增长、市场份额、真实利润或供应链能力。",
777
- ]},
778
- table("本报告来源校验", [("module", "模块"), ("role", "角色"), ("name", "文件"), ("sha256", "SHA-256")], source_rows),
779
- ])
780
-
781
- report = {
782
- "contract": "compact-workbench@1.0",
783
- "meta": {"title": manifest["title"], "subtitle": manifest["subject"],
784
- "footer": ("开发模式审阅稿|" if manifest.get("mode") == "development" else "研究报告|")
785
- + "结论用于决定进一步研究顺序,不构成入场或采购决定。",
786
- "source_payload_sha256": evidence["cleaned_workbook_sha256"],
787
- "artifact_binding": {
788
- "prototype_labels_sha256": srcs["visual"]["labels"]["sha256"],
789
- "prototype_summary_sha256": srcs["visual"]["prototypes"]["sha256"],
790
- "comparison_evidence_sha256": srcs["decision"]["evidence"]["sha256"],
791
- "opportunity_cards_sha256": srcs["decision"]["cards"]["sha256"],
792
- },
793
- "external_images": "allow_https"},
794
- "shell": {"brand_mark": "研", "brand_title": "搜索选品研究", "brand_subtitle": manifest["subject"],
795
- "nav_label": "研究章节", "search": True, "search_placeholder": "搜索当前章节",
796
- "date_range": manifest.get("date_label", "搜索结果快照"), "sidebar_tabs": True,
797
- "reading_path": [{"view_id": id_, "label": label} for id_, label in [
798
- ("overview", "结论"), ("advertising", "广告"), ("title_roots", "词根"),
799
- ("geography", "地址"), ("price", "价格"), ("visual", "形态"),
800
- ("candidates", "组合"), ("opportunities", "机会"), ("evidence", "证据")]]},
801
- "assets": assets,
802
- "views": views,
803
- }
804
- if output_path.exists():
805
- raise FileExistsError(output_path)
806
- output_path.parent.mkdir(parents=True, exist_ok=True)
807
- output_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
808
- print(json.dumps({"output": str(output_path), "views": len(views), "cards": len(cards),
809
- "natural_products": len(products), "candidate_groups": len(candidates),
810
- "source_artifacts": len(source_rows), "assets": len(assets)}, ensure_ascii=False))
811
-
812
-
813
- def main() -> None:
814
- parser = argparse.ArgumentParser(description=__doc__)
815
- parser.add_argument("--manifest", required=True, type=Path)
816
- parser.add_argument("--output", required=True, type=Path)
817
- args = parser.parse_args()
818
- build(args.manifest, args.output)
819
-
820
-
821
- if __name__ == "__main__":
822
- main()