prepare-workshop-self-run-ledger.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. """Compute rehearsal tables from explicitly reviewed record membership, never keyword counts."""
  2. import csv
  3. import hashlib
  4. import json
  5. from pathlib import Path
  6. ROOT = Path(__file__).resolve().parents[1]
  7. OUT = ROOT / 'docs/workshop-self-run-20261110'
  8. EVIDENCE = ROOT / 'work/workshop-self-run-20261110/evidence'
  9. load = lambda name: json.loads((EVIDENCE / (name + '.json')).read_bytes())
  10. # Membership assigned by this assistant after reading all 49 full normalized review bodies.
  11. # This is one model's coding, not independent human validation or a second model review.
  12. TAGS = {
  13. 'preparation_positive': {2, 5, 9, 11, 14, 19, 22, 23, 36, 44},
  14. 'no_wash_positive': {2, 5},
  15. 'draining_procedure': {19},
  16. 'draining_complaint': set(),
  17. 'texture_positive': {2, 5, 14, 19, 30, 31, 32, 36},
  18. 'flavour_negative': {47},
  19. }
  20. TAG_NAMES = {'preparation_positive': '明确表达准备/使用便利的正向意见', 'no_wash_positive': '不用洗碗的正向表达',
  21. 'draining_procedure': '明确描述沥水步骤(不是抱怨)', 'draining_complaint': '明确表达沥水困扰',
  22. 'texture_positive': '口感/面条结构正向表达', 'flavour_negative': '明确口味负向表达'}
  23. EXCLUDED = {35: '只有“收到”,无产品意见;不进入主题分母', 48: '只有“收到”,无产品意见;不进入主题分母'}
  24. PENDING = {18: '拌面购买规格与“汤头”描述存在用途歧义;先不用于主题统计',
  25. 49: '仅“🉑”,对本题主题判定含义不足;待核,不当作明确未提及'}
  26. reviews = load('banmian-reviews')['records']
  27. assert len(reviews) == 49 and load('banmian-reviews')['nextCursor'] is None
  28. rows = []
  29. for i, original in enumerate(reviews, 1):
  30. d = load(f'review-{i:02}')['record']
  31. assert d['id'] == original['record_id'] and d['content'] == original['content']
  32. status = 'excluded' if i in EXCLUDED else 'pending' if i in PENDING else 'usable'
  33. row = {'key': f'E{i:02}', 'entity': 'reviews', 'recordId': d['id'], 'productId': d['product_id'],
  34. 'platform': original['platform'], 'brand': original['brand'], 'purchasedSku': d['purchased_sku_text'],
  35. 'dateRaw': d['review_date_raw'], 'quote': d['content'], 'appendText': d.get('append_content_json', ''),
  36. 'status': status, 'reason': EXCLUDED.get(i) or PENDING.get(i) or '规范化原文/归属可读;仅用于本批自述的主题判断,不验证真实性或性能',
  37. 'tags': [name for name, members in TAGS.items() if i in members],
  38. 'source': d['source'], 'currentDefault': d.get('is_default'), 'historicalIssues': d.get('issues', []),
  39. 'responseFile': f'../../work/workshop-self-run-20261110/evidence/review-{i:02}.json'}
  40. assert row['status'] == 'usable' or not row['tags']
  41. rows.append(row)
  42. usable = [r for r in rows if r['status'] == 'usable']
  43. assert len(usable) == 45
  44. duplicate_sets = []
  45. by_text = {}
  46. for r in usable:
  47. by_text.setdefault(r['quote'].strip(), []).append(r['key'])
  48. for members in by_text.values():
  49. if len(members) > 1:
  50. duplicate_sets.append(members)
  51. assert len(by_text) == 43
  52. statistics = []
  53. for platform, pid in dict.fromkeys((r['platform'], r['productId']) for r in rows):
  54. group = [r for r in usable if r['platform'] == platform and r['productId'] == pid]
  55. for key, label in TAG_NAMES.items():
  56. members = [r['key'] for r in group if key in r['tags']]
  57. texts = {r['quote'].strip() for r in group}
  58. positive_texts = {r['quote'].strip() for r in group if key in r['tags']}
  59. statistics.append({'platform': platform, 'productId': pid, 'theme': key, 'label': label,
  60. 'n': len(members), 'N': len(group), 'percent': round(len(members) / len(group) * 100, 1),
  61. 'members': members, 'denominatorMembers': [r['key'] for r in group],
  62. 'distinctTextN': len(texts), 'distinctTextn': len(positive_texts),
  63. 'unit': '本次已判定的评价记录;不是独立消费者',
  64. 'reviewState': '同一助手编码和回读;无独立第二模型/人工复核'})
  65. social = []
  66. SOCIAL_USE = {
  67. 1: ('context', '2022年视频简介;可见无锅表述,不等于看完视频或本品牌操作实测'),
  68. 2: ('title-only', '正文“-”;标题推广与营多相关,但不能将营销语当消费者体验'),
  69. 3: ('counterexample', '明确有小锅可煮、无冰箱;是本题无锅范围反例与耐放线索'),
  70. 4: ('context', '明确夜间食堂无饭、找不到热水、借水/被帮助煮的自述;无品牌归属'),
  71. 5: ('adjacent-claim', '热干面相邻品类的推广表达;不迁移为本款拌面性能'),
  72. 6: ('excluded', '明确电煮锅带货佣金,不作为面食消费者需求证词'),
  73. 7: ('title-only', '正文“-”;气味只为题名线索,不形成已核痛点'),
  74. 8: ('context-tags', '主要为汤面/宿舍标签,缺行为细节;不当完整使用事件'),
  75. 9: ('adjacent-context', '2023年面皮体验与室友推荐;相邻替代线索,不证明2026增长'),
  76. 10: ('excluded', '无正文、未看视频,只保留检索索引'),
  77. }
  78. for i in range(1, 11):
  79. d = load(f'social-{i:02}')['record']
  80. social.append({'key': f'S{i:02}', 'entity': 'social', 'recordId': d['id'], 'title': d['title'], 'quote': d['content'],
  81. 'dateRaw': d['published_at_raw'], 'url': d['url'], 'source': d['source'],
  82. 'use': SOCIAL_USE[i][0], 'reason': SOCIAL_USE[i][1], 'fulltextFlag': d['fulltext_available']})
  83. products = []
  84. for i in range(1, 8):
  85. d = load(f'product-{i:02}')['record']
  86. products.append({'key': f'P{i:02}', 'entity': 'products', 'recordId': d['id'], 'title': d['title'], 'url': d['url'],
  87. 'dateRaw': d.get('source_time_raw'), 'source': d['source'],
  88. 'prices': [{k: r.get(k) for k in ['record_id', 'amount_minor', 'price_basis', 'source_time_raw', 'source_day']}
  89. for r in load(f'product-{i:02}-prices')['records']],
  90. 'specs': [{k: r.get(k) for k in ['record_id', 'spec_text', 'net_weight_g', 'pack_count', 'spec_verification']}
  91. for r in load(f'product-{i:02}-specs')['records']]})
  92. ledger = {'schema': 'ksf.workshop-self-run.evidence.v1', 'dataNature': 'real read-only database records, not fabricated review samples',
  93. 'scope': {'reviewsRetrievedAndRead': 49, 'reviewUsable': 45, 'reviewPending': 2, 'reviewExcluded': 2,
  94. 'distinctUsableTexts': 43, 'productsDetailed': 7, 'socialCandidatesShown': 50,
  95. 'socialQueryMatches': 100, 'socialDetailedPurposivelySelected': 10,
  96. 'socialRepresentativeSample': False, 'newCollection': False, 'sourceArchiveRehashed': False},
  97. 'duplicateTextGroups': duplicate_sets, 'reviews': rows, 'social': social, 'products': products, 'statistics': statistics}
  98. (OUT / '证据与编码.json').write_text(json.dumps(ledger, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  99. with (OUT / '评价逐条编码.csv').open('w', encoding='utf-8-sig', newline='') as f:
  100. fields = ['key', 'recordId', 'productId', 'platform', 'purchasedSku', 'dateRaw', 'quote', 'status', 'reason', 'tags', 'sourceRecordId', 'archiveSha256', 'assetSha256', 'lineNumber']
  101. w = csv.DictWriter(f, fieldnames=fields)
  102. w.writeheader()
  103. for r in rows:
  104. w.writerow({**{k: r[k] for k in fields if k in r and k != 'tags'}, 'tags': ' | '.join(r['tags']),
  105. 'sourceRecordId': r['source']['id'], 'archiveSha256': r['source'].get('archiveSha256'),
  106. 'assetSha256': r['source'].get('assetSha256'), 'lineNumber': r['source'].get('line_number')})
  107. with (OUT / '提及率复算.csv').open('w', encoding='utf-8-sig', newline='') as f:
  108. fields = ['platform', 'productId', 'label', 'n', 'N', 'percent', 'distinctTextn', 'distinctTextN', 'members', 'denominatorMembers']
  109. w = csv.DictWriter(f, fieldnames=fields)
  110. w.writeheader()
  111. for s in statistics:
  112. w.writerow({k: ' | '.join(s[k]) if isinstance(s[k], list) else s[k] for k in fields})
  113. lines = ['# 真实资料与本地分析索引', '', '全部来自同一正式代际的只读API;E/P/S为本地引用键,不是业务库编号。来源指纹来自服务器回读,本轮未重新下载源ZIP验证。编码为同一助手所作,未经独立第二模型或人工复核。', '',
  114. '## 覆盖', '', json.dumps(ledger['scope'], ensure_ascii=False), '', '## 商品', '']
  115. for r in products:
  116. lines += [f"- **{r['key']}** [{r['title']}]({r['url']});`{r['recordId']}`;来源 `{r['source']['id']}`。"]
  117. lines += ['', '## 社媒(仅定向回读的10条,不计算人群占比)', '']
  118. for r in social:
  119. lines += [f"### {r['key']}|{r['title']}", '', f"[{r['recordId']}]({r['url']}) · {r['dateRaw']}", '',
  120. '> ' + (r['quote'] or '(正文为空)').replace('\n', '\n> '), '', f"用途:{r['reason']}。来源 `{r['source']['id']}`。", '']
  121. lines += ['## 电商评价', '']
  122. for r in rows:
  123. lines += [f"### {r['key']}|{r['recordId']}", '', f"{r['platform']} · {r['purchasedSku']} · {r['dateRaw']}", '',
  124. '> ' + r['quote'].replace('\n', '\n> '), '', f"用途:{r['status']};{r['reason']}。标签:{'、'.join(r['tags']) or '本题主题未明确提及'}。", '',
  125. f"来源 `{r['source']['id']}`;原件行号 {r['source'].get('line_number')};归档指纹 `{r['source'].get('archiveSha256')}`。", '']
  126. (OUT / '证据索引.md').write_text('\n'.join(lines), encoding='utf-8')
  127. print(json.dumps({'ok': True, **ledger['scope'], 'duplicateTextGroups': duplicate_sets,
  128. 'convenience': [{k: s[k] for k in ['platform', 'productId', 'n', 'N', 'distinctTextn', 'distinctTextN']} for s in statistics if s['theme'] == 'preparation_positive']}, ensure_ascii=False))