seed_long_report.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. """seed_long_report.py —— 压力测试:把示例报告按真实篇幅扩到约 1.6 万字,写回同一条成果。
  2. 目的:验收第 13 项「长报告可读(1 万字级不崩)」。
  3. 可重复执行:先运行 seed_report.py 得到基准报告,再运行本脚本扩容;
  4. 再运行一次 seed_report.py 即可恢复基准版本。
  5. python3 tools/seed_long_report.py --dry-run
  6. python3 tools/seed_long_report.py
  7. """
  8. import json
  9. import os
  10. import sys
  11. import urllib.request
  12. BASE = "https://kangshifu.fmode.cn/parse"
  13. APP_ID = "FKieRbIoGt_main"
  14. MK = os.environ.get("PARSE_MASTER_KEY", "")
  15. DRY = "--dry-run" in sys.argv
  16. H = {"X-Parse-Application-Id": APP_ID, "Content-Type": "application/json"}
  17. if MK:
  18. H["X-Parse-Master-Key"] = MK
  19. TARGET = "夜宵场景低卡高蛋白拌面 - competitor_report"
  20. # 扩写素材:按"场景 × 人群 × 行为"把每条痛声讲细,内容仍是数据,渲染仍走同一套 section 类型
  21. EXPAND = {
  22. "para": (
  23. "把这条原声放回它的语境里看:说话的人并不是在挑剔口味,而是在描述一种每天都会发生的将就——"
  24. "手边只有一壶开水,泡出来的面软塌成一团,酱包再香也救不回来。这类评论在几个平台上高度相似,"
  25. "说明它不是个别人的偏好,而是「速食拌面」这个形态本身在场景里暴露出来的结构性缺口。"
  26. "如果我们只把它读成「消费者对口感有要求」,就得不出任何可以立项的动作;但如果把它读成"
  27. "「在没有锅的条件下,当前工艺无法交付有嚼劲的面」,那么要解决的问题就很具体:面饼复水后的结构强度。"
  28. ),
  29. "table": {
  30. "cols": ["分层", "典型人群", "出现频次", "原声样例(节选)", "对产品的含义"],
  31. "rows": [
  32. ["加班夜宵(22:00–1:00)", "互联网 / 设计 / 医护夜班", 486, "「泡完就坨,根本没有嚼劲」", "夜宵是最高频场景,口感是此刻唯一的取悦点"],
  33. ["宿舍速食", "高校学生 / 合租青年", 372, "「只有麻酱和辣,想吃川味没有」", "口味需要地域细分,而不是继续加辣度"],
  34. ["办公室午餐", "白领 / 工位即食", 258, "「拌好以后坨掉,边工作边吃根本不行」", "需要耐放、不易坨的形态与包装"],
  35. ["露营 / 户外", "周末露营 / 徒步", 193, "「户外多一步倒汤,就不想买拌面」", "包装结构要能把「倒汤」这一步去掉"],
  36. ["家庭囤货", "三口之家 / 老人同住", 311, "「一家人吃,口味不够分」", "大包装要有组合口味,而不是同一口味堆量"],
  37. ],
  38. },
  39. "findings": [
  40. {"id": "f-a", "conclusion": "「软塌」的根因是工艺,不是口味",
  41. "attitude": "在 214 条相关原声里,提到口感的多于提到味道的。把预算投在调味迭代上,解决不了复购问题——这是产品适配章节要回答的第一件事。", "evidence": "c-texture"},
  42. {"id": "f-b", "conclusion": "地域风味是空档,不是「再加一个辣」",
  43. "attitude": "川味的麻、西北油泼的香、东北酸菜的酸,在搜索端都有人找,在货架上都没有。缺的不是辣度,是地方性。", "evidence": "c-sauce"},
  44. {"id": "f-c", "conclusion": "露营不是「没人买」,是「多一步就不买」",
  45. "attitude": "户外用户的支付意愿不低,但对步骤极其敏感:倒面汤这一步就会损失一半潜在用户。包装上的每一个动作,都要算成本。", "evidence": "c-portable"},
  46. ],
  47. "funnel": [
  48. {"stage": "S1", "name": "品类池", "value": 46, "unit": "个细分品类", "scope": "拌面 / 泡面 / 鲜面 / 自热 / 冻干 / 螺蛳粉", "note": "先把品类地图铺开,确定竞争发生哪条线上。"},
  49. {"stage": "S2", "name": "目标人群", "value": 2.7, "unit": "亿人", "scope": "18–35 岁,每周至少吃一次速食面条的城市人群", "note": "用 VOC 去掉两端:只吃外卖的人、只吃现煮鲜面的人。"},
  50. {"stage": "S3", "name": "细分市场", "value": 3000, "unit": "万人", "scope": "把速食面当一顿正餐而不是垫一口的人", "note": "「不将就」的需求集中在这一格。"},
  51. {"stage": "S4", "name": "可切入场景", "value": 900, "unit": "万人", "scope": "加班 / 宿舍 / 露营 三类没有锅的场景", "note": "场景越多,包装与口味越难统一;先做最大的一块。"},
  52. {"stage": "S5", "name": "首推机会", "value": 180, "unit": "万人", "scope": "「不需要锅也能吃到锅气」的即食鲜拌面", "note": "把「锅气」做成一句能记住、能复述的话。"},
  53. ],
  54. }
  55. def call(method, path, body=None):
  56. data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
  57. req = urllib.request.Request(BASE + path, data=data, headers=H, method=method)
  58. with urllib.request.urlopen(req, timeout=30) as r:
  59. return json.loads(r.read().decode() or "{}")
  60. def expand(report):
  61. """每章:原文 ×2 + 追加一节"长论证";结论块扩到 3 条;表格换成更细的分层表。
  62. (示例内容本身是短篇骨架,这里只是按真实报告的篇幅把它撑长,用于压测。)"""
  63. for ch in report.get("chapters", []):
  64. # 每章追加 2 节长论证,让整篇达到真实报告的万字级篇幅(约 2 万字)
  65. secs = ch.setdefault("sections", [])
  66. for k in range(1, 3):
  67. parts = []
  68. for j in range(4):
  69. parts.append(EXPAND["para"])
  70. parts.append(f"回到第 {j + 1} 个观察点:{EXPAND['table']['cols'][(j + k) % 5]}。"
  71. "这不是某一款产品的问题,而是整个品类在没有锅的场景里共同面对的约束;"
  72. "谁先把这条约束变成产品力,谁就拿到这个场景的解释权。")
  73. secs.append({"id": f"{ch['id']}-long{k}", "no": f"{ch.get('no')}.{k + 3}",
  74. "title": f"长论证 {k} · 把问题讲透", "kind": "longpara", "text": "\n\n".join(parts)})
  75. secs = ch.get("sections", [])
  76. extra = []
  77. for s in secs:
  78. k = s.get("kind")
  79. if k == "para":
  80. s["text"] = s["text"] + EXPAND["para"]
  81. extra.append({"id": s["id"] + "-x", "no": (s.get("no") or "") + ".1", "title": "细读 · 正反两面",
  82. "kind": "two", "data": {
  83. "can": ["有稳定的高频场景(夜宵 / 宿舍 / 办公)", "我方产线与调味能力可覆盖", "渠道能触达即食购买点"],
  84. "cannot": ["无法在两周内改变面饼工艺", "不能宣称疗效或替代正餐营养", "价格不能与外卖正面竞争"]}})
  85. elif k == "table":
  86. s["title"] = s["title"] + " · 分层明细"
  87. s["data"] = EXPAND["table"]
  88. elif k == "findings":
  89. s["data"] = EXPAND["findings"]
  90. elif k == "funnel":
  91. s["data"] = EXPAND["funnel"]
  92. secs.extend(extra)
  93. ch["lead"] = ch.get("lead", "") + EXPAND["para"][:120]
  94. return report
  95. def main():
  96. if not MK and not DRY:
  97. sys.exit("PARSE_MASTER_KEY 未设置")
  98. rows = call("GET", "/classes/KfArtifact?limit=1000&order=createdAt")["results"]
  99. target = next((a for a in rows if a.get("title") == TARGET), None)
  100. if not target or not target.get("report"):
  101. sys.exit("先运行 tools/seed_report.py")
  102. rep = expand(target["report"])
  103. body = json.dumps(rep, ensure_ascii=False)
  104. if DRY:
  105. print(json.dumps({"bytes": len(body.encode())}, ensure_ascii=False))
  106. return
  107. call("PUT", f"/classes/KfArtifact/{target['objectId']}", {"report": rep})
  108. print("long report written:", target["objectId"], "| bytes", len(body.encode()))
  109. if __name__ == "__main__":
  110. main()