seed_report.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. """seed_report.py —— 给一条成果写入一份完整的示例报告(康师傅拌面 · 按江中猴菇饮骨架)。
  2. 报告存在 KfArtifact.report(结构化 JSON),看板 js/views/report.js 负责渲染。
  3. 本脚本可重复执行:每次覆盖同一条成果的 report 字段,不改动其他人的数据。
  4. python3 tools/seed_report.py # 写入(需要 PARSE_MASTER_KEY)
  5. python3 tools/seed_report.py --dry-run # 只打印摘要,不写库
  6. 结构(六章对应五个阶段,见 README「报告数据结构」):
  7. cover 封面 → chapters[ch1..ch6](每章 lead + 若干 section)+ evidence 证据链
  8. evidence[conclusionId] = { claim, ownerCount, quotes[{artId, author, text, ref}], derivedFrom }
  9. derivedFrom 引用现存 KfArtifact 的 objectId:结论旁点「证据链」能看到是哪几条成果撑起来的。
  10. 看板会把结论挂到它引用的成果上:在列表里点开那些成果,能看到「支撑了哪条结论」。
  11. 字段一律是数据,不是 HTML:渲染端负责排版与转义,避免把一段 HTML 塞进库里。
  12. 中文正文里的引用一律用「」,避免和外层字符串引号打架。
  13. """
  14. import json
  15. import os
  16. import sys
  17. import urllib.error
  18. import urllib.request
  19. BASE = "https://kangshifu.fmode.cn/parse"
  20. APP_ID = "FKieRbIoGt_main"
  21. MK = os.environ.get("PARSE_MASTER_KEY", "")
  22. DRY = "--dry-run" in sys.argv
  23. H = {"X-Parse-Application-Id": APP_ID, "Content-Type": "application/json"}
  24. if MK:
  25. H["X-Parse-Master-Key"] = MK
  26. # 选哪条成果承载示例报告:张伟 S1 的品类竞品报告(已有 project / ownerSession 出身)
  27. TARGET_TITLE_KEYWORD = "夜宵场景低卡高蛋白拌面 - competitor_report"
  28. def call(method, path, body=None):
  29. data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
  30. req = urllib.request.Request(BASE + path, data=data, headers=H, method=method)
  31. with urllib.request.urlopen(req, timeout=30) as r:
  32. return json.loads(r.read().decode() or "{}")
  33. def fetch(cls):
  34. return call("GET", f"/classes/{cls}?limit=1000&order=createdAt").get("results", [])
  35. def q(art_id, author, text, ref, likes=None):
  36. o = {"artId": art_id, "author": author, "text": text, "ref": ref}
  37. if likes is not None:
  38. o["likes"] = likes
  39. return o
  40. def build(ids):
  41. """ids: 把示例报告里用到的结论挂到真实成果 objectId 上(缺失为 None,看板显示「证据未接入」)。"""
  42. E = {
  43. "c-texture": {
  44. "claim": "「太软没嚼劲」是拌面复购的第一杀手,且被不同成员独立提到",
  45. "ownerCount": 4,
  46. "derivedFrom": [ids.get("zw_pain"), ids.get("ln_pain"), ids.get("wf_opp")],
  47. "quotes": [
  48. q(ids.get("zw_pain"), "夜宵党_312", "泡完就坨,没有一碗速食拌面该有的嚼劲,第二次就不会买了", "抖音评论", 412),
  49. q(ids.get("ln_pain"), "上班族_077", "面饼太软,拌酱以后像糊糊,想要那种手擀面的筋道", "小红书笔记", 238),
  50. q(ids.get("wf_opp"), "学生党_156", "宿舍没有锅,只能泡,泡出来的口感比煮的差一截", "B站评论", 96),
  51. q(None, "口味控_044", "康师傅的拌面面饼偏软,拌的时候一直断", "电商差评", 0),
  52. ],
  53. },
  54. "c-sauce": {
  55. "claim": "酱包集中在「麻酱 / 香辣」两类,地域风味是空档",
  56. "ownerCount": 3,
  57. "derivedFrom": [ids.get("jn_pain"), ids.get("wf_pain"), ids.get("hm_pain")],
  58. "quotes": [
  59. q(ids.get("jn_pain"), "地域党_208", "想吃川味,但是只有辣——麻香和香辣是两种东西", "小红书笔记", 166),
  60. q(None, "江浙沪_021", "拌面全是麻酱和辣,清淡口味的没得选", "电商评论", 0),
  61. ],
  62. },
  63. "c-price": {
  64. "claim": "学生与加班人群对份量敏感,「吃不饱」比「难吃」更容易被差评",
  65. "ownerCount": 2,
  66. "derivedFrom": [ids.get("wf_opp"), ids.get("ln_pain")],
  67. "quotes": [
  68. q(None, "大胃王_553", "一包根本吃不饱,加一包又要再冲一次,很麻烦", "电商评论", 0),
  69. q(None, "加班党_119", "十块钱的拌面,吃完还是很饿,不如点外卖", "抖音评论", 302),
  70. ],
  71. },
  72. "c-portable": {
  73. "claim": "露营 / 宿舍场景的核心矛盾是「没有锅也要有锅气」",
  74. "ownerCount": 2,
  75. "derivedFrom": [ids.get("cj_opp"), ids.get("cj_pain")],
  76. "quotes": [
  77. q(None, "露营_031", "露营带的都是泡面,想要拌面,但得煮,太麻烦", "小红书笔记", 521),
  78. q(None, "户外_882", "户外最简单就是开水,拌面要倒掉面汤,步骤多一步就不想买", "抖音评论", 143),
  79. ],
  80. },
  81. "c-noodle": {
  82. "claim": "地域风味(西北 / 西南 / 东北)在年轻人里的搜索热度高于现有 SKU 覆盖",
  83. "ownerCount": 3,
  84. "derivedFrom": [ids.get("zw_report"), ids.get("ly_opp"), ids.get("hm_pain")],
  85. "quotes": [
  86. q(None, "风味_1", "重庆小面的麻辣鲜香,速食里基本找不到", "小红书笔记", 389),
  87. q(None, "风味_2", "油泼面那种宽面,速食做不出来", "抖音评论", 274),
  88. q(None, "风味_3", "东北的酸菜拌面,速食没有", "微博话题", 0),
  89. ],
  90. },
  91. "c-plain": {
  92. "claim": "「不将就」的日常一餐是最大增量,但用户不愿意为此多花超过 3 元",
  93. "ownerCount": 2,
  94. "derivedFrom": [ids.get("ln_report"), ids.get("hm_opp")],
  95. "quotes": [
  96. q(None, "白领_267", "加班到十点,想吃口热的,又不想吃泡面的汤汤水水", "小红书笔记", 198),
  97. q(None, "白领_341", "更愿意为口感多付,但超过十五块就点外卖了", "B站评论", 87),
  98. ],
  99. },
  100. # 反面证据:一条被标成记录层的重复/推演成果,拿它说明「我们排除了什么」
  101. "c-weak": {
  102. "claim": "「老年人更爱汤面」这类推演,没有原文支撑就不能作为立项依据",
  103. "ownerCount": 1,
  104. "derivedFrom": [ids.get("zt_record")],
  105. "quotes": [],
  106. },
  107. }
  108. funnel = [
  109. {"stage": "S1", "name": "品类池", "value": 46, "unit": "", "scope": "国内速食面品类(拌面 / 泡面 / 鲜面 / 自热)",
  110. "note": "先把品类地图铺开,确定我们在哪条线上竞争。"},
  111. {"stage": "S2", "name": "目标人群", "value": 2.7, "unit": "亿", "scope": "18–35 岁、每周至少吃一次速食面条的一线到三线城市人群",
  112. "note": "用 VOC 原声过滤掉只吃外卖和只吃高端鲜面两端。"},
  113. {"stage": "S3", "name": "拌面细分市场", "value": 3000, "unit": "万人", "scope": "愿意把速食面当一顿正餐而不是垫一口的人",
  114. "note": "S3 机会识别:这里才是「不将就」需求的集中区。"},
  115. {"stage": "S4", "name": "可切入场景", "value": 900, "unit": "万人", "scope": "加班 / 宿舍 / 露营 三类「没有锅」的场景",
  116. "note": "S4 验证:三个场景的用户结构、付费意愿与竞品密度分别打分。"},
  117. {"stage": "S5", "name": "首推机会", "value": 180, "unit": "万人", "scope": "「不需要锅也能吃到锅气」的即食鲜拌面",
  118. "note": "S5 卖点设计:把「锅气」做成一句能记住的话。"},
  119. ]
  120. xray = {
  121. "cols": ["场景", "用户此刻的选择", "未满足需求(VOC 原声)", "VOC 条数", "机会"],
  122. "rows": [
  123. ["加班夜宵", [["泡面(汤面)", 61], ["外卖拌饭", 24], ["自热锅", 9]],
  124. ["面饼泡后偏软、没有嚼劲", "汤多、吃完撑但不满足"], 486, "高", "c-texture"],
  125. ["宿舍速食", [["泡面", 55], ["干拌面(麻酱/香辣)", 33]],
  126. ["只有麻酱和辣两种口味", "一包吃不饱、加一包又麻烦"], 372, "高", "c-sauce"],
  127. ["办公室午餐", [["便利店盒饭", 40], ["外卖", 38], ["速食拌面", 12]],
  128. ["拌好以后坨掉、没法边工作边吃", "等热水 3 分钟也被嫌慢"], 258, "中", "c-plain"],
  129. ["露营 / 户外", [["泡面", 48], ["干粮", 30]],
  130. ["没有锅也能有锅气", "拌面得倒面汤,多一步就不买"], 193, "中", "c-portable"],
  131. ["家庭囤货", [["大包装泡面", 70], ["挂面", 18]],
  132. ["一家人吃时口味不够分", "只看价格、不看口感"], 311, "低", "c-price"],
  133. ],
  134. }
  135. fit = {
  136. "can": [
  137. "面饼工艺:非油炸 + 蒸煮线的复合工艺已经是现有产线能力",
  138. "酱包研发:川、湘、西北三地的调味样已经在研发中心储备",
  139. "渠道:铺到 KA 与便利店,即食场景天然可达",
  140. "品牌:在速食面心智里有默认信任,降低尝试成本",
  141. ],
  142. "cannot": [
  143. "对标现煮手擀面的筋道——速食工艺做不到,宣称「手擀」是虚假宣传",
  144. "做成「不用水」的常温即食鲜面——目前保鲜与货架期都不支持",
  145. "承诺「吃饱」——份量上去之后成本与价格带冲突,不能两头都要",
  146. ],
  147. "transferCols": ["成功要素", "来源", "能否迁移到拌面", "怎么落地"],
  148. "transfer": [
  149. ["固定场景", "泡面(加班夜宵 / 宿舍)", "✅ 可迁移", "把「夜宵」写进包装正面,做 21:00–1:00 的场景标签"],
  150. ["大口味心智", "红烧牛肉面", "✅ 可迁移", "用「红烧」做第一支口味的锚点,降低认知成本"],
  151. ["低价尝鲜", "袋装泡面 2.5 元", "⚠️ 部分迁移", "只做小规格尝鲜装,主推规格定价 8–10 元"],
  152. ["煮食依赖", "鲜面 / 挂面", "❌ 不可迁移", "这是速食拌面必须绕开的红线"],
  153. ],
  154. "redlineCols": ["失败因子", "风险", "在拌面上会怎么发生"],
  155. "redline": [
  156. ["产品反效果", "极高", "酱太咸、口感坨掉 → 差评集中"],
  157. ["宣称与实际体验矛盾", "高", "宣称「锅气」但泡出来是糊的 → 信任崩塌"],
  158. ["品类联想错位", "中", "把拌面当零食卖 → 复购低"],
  159. ["价格高于外卖替代", "中", "定价超过 15 元 → 用户直接用外卖替代"],
  160. ],
  161. }
  162. positioning = {
  163. "ladder": [
  164. ["#1", "外卖 / 现煮面", "愿意花钱,但要像一顿饭"],
  165. ["#2", "瓶装泡面", "便宜、快、默认选择"],
  166. ["#3", "干拌面(麻酱 / 香辣)", "偶尔换个口味"],
  167. ["#4", "自热锅", "周末 / 露营的重口味方案"],
  168. ["#5", "⭐ 空着的位置", "想要不将就,但没有锅"],
  169. ],
  170. "statement": "不用锅,也能拌出面馆的口感",
  171. "for_who": "加班到深夜、宿舍里只有一壶开水、露营车上只有一个保温壶的人",
  172. "axis": [
  173. ["vs 泡面", "泡着吃是软塌的根源", "我们是拌的,口感由产品本身决定"],
  174. ["vs 外卖拌饭", "外卖是一顿饭,但我们更快、更热", "夜宵 3 分钟,比等外卖快"],
  175. ["vs 自热锅", "自热锅是重口味的仪式感,我们的场景更日常", "办公室 / 宿舍也能用"],
  176. ],
  177. "alt": ["B 案:「深夜的面馆,三分钟到桌」", "C 案:「宿舍里的一碗热拌面」(注意:不得使用手擀字样)"],
  178. }
  179. actions = [
  180. ["P0-1", "面饼工艺样机", "研发中心", "2 周", "非油炸 + 蒸煮复合工艺,目标「泡 4 分钟后仍有嚼劲」,与现有面饼盲测对比", "P0"],
  181. ["P0-2", "第一支口味锁定", "产品部", "2 周", "以红烧为锚,川味麻辣与西北油泼两个备选,各 200 份盲测", "P0"],
  182. ["P0-3", "场景包装", "品牌部", "3 周", "包装正面写场景(加班 / 宿舍 / 露营),背面写「不用锅」的冲泡步骤", "P0"],
  183. ["P1-1", "便利店夜宵端架测试", "渠道部", "1 个月", "选 50 家 24h 便利店做端架,观察 21 点后的动销", "P1"],
  184. ["P1-2", "露营渠道试水", "渠道部", "2 个月", "与户外品牌联名小规格装,验证「多一步就不买」", "P1"],
  185. ["P1-3", "地域风味扩展评估", "产品部", "3 个月", "基于 S1 竞品报告的地域风味搜索数据,评估第二支口味", "P1"],
  186. ]
  187. milestones = [
  188. ["M1", "口感验证",
  189. ["盲测中「泡后仍有嚼劲」的通过率 ≥ 60%", "与现煮面盲测对比,偏好度 ≥ 40%"],
  190. "通过率 < 40% → 回到工艺选择;< 25% → 放弃「不要锅」概念"],
  191. ["M2", "场景验证",
  192. ["50 家便利店夜宵时段动销率", "露营联合装复购率"],
  193. "动销率 < 10% → 调整端架与包装;仍无起色 → 换场景"],
  194. ["M3", "复购验证",
  195. ["首月复购率 ≥ 15%", "NPS ≥ 30"],
  196. "复购率 < 8% → 口感仍是问题;NPS < 20 → 定位偏差"],
  197. ]
  198. gaps = [
  199. ["现有产品的真实口感差距", "只有二手评论", "面饼含水量 / 复水时间参数 + 竞品盲测", "决定工艺路线", "高"],
  200. ["露营场景的实际购买路径", "S4 只做了问卷推演", "3 个营地的现场观察", "决定联名规格", "中"],
  201. ["外卖价格带的替代阈值", "没有价格敏感度测试", "10 / 12 / 15 元三档价格测试", "决定定价", "高"],
  202. ]
  203. return {
  204. "version": 1,
  205. "title": "康师傅即食鲜拌面 · 场景与口感机会洞察",
  206. "subtitle": "从泡着吃到拌着吃:5 大场景 × 现有产品 × 未满足需求",
  207. "method": "VOC 原声提取 × 场景拆解 × 竞品矩阵 × 机会验证评分(S1 品类 → S5 卖点)",
  208. "sources": "抖音 186 条视频 / 小红书 402 篇笔记 / 电商评论 1,930 条 / 便利店端架观察 12 家",
  209. "pipeline": [{"label": "原始 VOC 采集", "value": 2727}, {"label": "去噪去重", "value": 1190},
  210. {"label": "精准 VOC", "value": 730}],
  211. "filterRate": "73%",
  212. "generatedAt": "2026-10-29T10:20:00.000Z",
  213. "structure": "封面 → 全景漏斗 → 嵌套洞察 → 痛声地图 → 产品适配 → 定位推导 → 行动蓝图",
  214. "kpis": [
  215. {"value": 2727, "unit": "条", "label": "原始 VOC", "note": "5 个平台、23 个关键词,30 天抓取"},
  216. {"value": 730, "unit": "条", "label": "精准 VOC", "note": "过滤率 73%,剔除广告、重复与无场景评论"},
  217. {"value": 5, "unit": "个", "label": "核心场景", "note": "加班夜宵 / 宿舍 / 办公室 / 露营 / 家庭囤货"},
  218. {"value": 2.7, "unit": "亿人", "label": "目标人群", "note": "18–35 岁,每周至少一次速食面条的城市人群"},
  219. ],
  220. "executive": [
  221. "拌面的问题不是没人吃,是吃得将就——用户想吃一碗像饭馆的拌面,但没有锅。",
  222. "「软塌 / 坨掉」是复购第一杀手,出现频次高于「难吃」;三份独立成果都指向同一件事。",
  223. "口味集中在麻酱与香辣两类,川味麻辣、西北油泼在搜索端有热度、产品端是空白。",
  224. "露营是「知道痛但还没买」的场景:多一步(倒面汤)就会损失一半潜在用户。",
  225. ],
  226. "chapters": [
  227. {
  228. "id": "ch1", "no": 1, "stage": "S1", "en": "MARKET FUNNEL", "title": "全景漏斗",
  229. "subtitle": "46 个品类 → 2.7 亿人 → 3000 万人 → 900 万人 → 180 万人",
  230. "lead": "把速食面这个大品类一路收窄到「没有锅、但想吃一碗好面」的那 180 万人——先看清我们是在几亿人里切多大一块。",
  231. "sections": [
  232. {"id": "s1-1", "no": "1.1", "title": "五级漏斗 · 从品类池到首推机会", "kind": "funnel", "data": funnel},
  233. {"id": "s1-2", "no": "1.2", "title": "品类全景 · 三条产品线的现状", "kind": "para",
  234. "text": "国内速食面大致分三线:瓶装泡面(占比最大、增长停滞)、干拌面(近两年增长最快、但口味高度同质)、鲜面 / 自热(价格高、场景窄)。拌面是唯一一条「增长 + 同质化」同时成立的线——这意味着机会不在有没有产品,而在谁先把口感做出区隔。"},
  235. ],
  236. },
  237. {
  238. "id": "ch2", "no": 2, "stage": "S2", "en": "NESTED INSIGHT", "title": "嵌套洞察",
  239. "subtitle": "5 大场景 × 现有产品选择 × 未满足需求 × 机会",
  240. "lead": "用户不是按品类买面的,是按「此刻在哪儿、有没有锅、饿到什么程度」买的——把这三个坐标叠起来,才看得到空白。",
  241. "sections": [
  242. {"id": "s2-1", "no": "2.1", "title": "5 大场景 × 现有产品 × 未满足需求", "kind": "matrix", "data": xray,
  243. "note": "VOC 条数为该场景下相关原声条数;末列指后文产品适配章节对应的结论。"},
  244. {"id": "s2-2", "no": "2.2", "title": "三条最锋利的结论", "kind": "findings", "data": [
  245. {"id": "f-1", "conclusion": "「太软没嚼劲」是第一杀手",
  246. "attitude": "口感不是加分项,是及格线。口感不合格的拌面,再怎么讲口味都不会复购。", "evidence": "c-texture"},
  247. {"id": "f-2", "conclusion": "口味空档在地域,不在辣度",
  248. "attitude": "再多一个更辣的口味没有意义;川味的麻、西北的油泼,才是搜索端在找、货架上没有的东西。", "evidence": "c-sauce"},
  249. {"id": "f-3", "conclusion": "露营场景「多一步就不买」",
  250. "attitude": "户外用户愿意为像样的一餐多付钱,但不愿意多带一口锅、多倒一次水。", "evidence": "c-portable"},
  251. ]},
  252. ],
  253. },
  254. {
  255. "id": "ch3", "no": 3, "stage": "S2", "en": "PAIN MAP", "title": "痛声地图",
  256. "subtitle": "每一条差评 → 是哪类用户 → 有没有原文 → 能不能立项",
  257. "lead": "把 730 条精准 VOC 里真正带原文的痛声挑出来,钉在场景上——没有原文的痛声只能当线索,不能当依据。",
  258. "sections": [
  259. {"id": "s3-1", "no": "3.1", "title": "痛点 × 场景 × 严重度", "kind": "table", "data": {
  260. "cols": ["痛点", "集中场景", "出现条数", "严重度", "能不能立项"],
  261. "rows": [
  262. ["面饼泡后偏软、坨掉", "加班夜宵 / 宿舍", 214, "严重", "能:工艺问题,可解"],
  263. ["只有麻酱 / 香辣两种口味", "宿舍 / 家庭", 168, "严重", "能:研发已有地域样"],
  264. ["一包吃不饱", "宿舍 / 加班", 132, "中", "有条件:需解决成本"],
  265. ["冲泡步骤多、要倒面汤", "露营 / 户外", 97, "中", "能:改包装结构"],
  266. ["酱太咸 / 太油", "全场景", 76, "中", "能:调味迭代"],
  267. ]}},
  268. {"id": "s3-2", "no": "3.2", "title": "反面证据:不能拿推演当依据", "kind": "findings", "data": [
  269. {"id": "f-4", "conclusion": "「老年人更爱汤面」只是一条推断",
  270. "attitude": "这条推断掉进了记录层:没有原文、缺出身,不能用来指导立项。把它留在证据链里,是为了说明我们排除了什么。",
  271. "evidence": "c-weak"},
  272. ]},
  273. ],
  274. },
  275. {
  276. "id": "ch4", "no": 4, "stage": "S4", "en": "PRODUCT FIT", "title": "产品适配",
  277. "subtitle": "能做到的 × 做不到的 × 可迁移的 × 红线",
  278. "lead": "不吹不黑——康师傅在速食面上真正的能力边界在哪?哪些是能把泡面的成功搬过来的,哪些是碰了就要翻车的。",
  279. "sections": [
  280. {"id": "s4-1", "no": "4.1", "title": "能做到的 vs 做不到的", "kind": "two", "data": fit},
  281. {"id": "s4-2", "no": "4.2", "title": "成功因子可迁移 vs 失败的红线", "kind": "fit", "data": fit},
  282. {"id": "s4-3", "no": "4.3", "title": "机会评分 · 需求强度 × 竞争烈度 × 我方能力", "kind": "score", "data": [
  283. ["不需要锅的鲜拌面", 9, 4, 8, 84, "✅ 首推"],
  284. ["川味麻辣拌面", 8, 6, 9, 71, "✅ 备选口味"],
  285. ["露营联名小装", 6, 3, 6, 58, "⏳ 小规模验证"],
  286. ["对标外卖的正餐拌面", 7, 9, 4, 41, "❌ 红海,不进"],
  287. ], "note": "评分 = 需求强度×5 + 我方能力×3 − 竞争烈度×2,满分 100;只用于排序,不作为决策依据。"},
  288. {"id": "s4-4", "no": "4.4", "title": "精准插入点", "kind": "para",
  289. "text": "结论:不进更便宜的价格战,也不进像外卖正餐的红海,而是切「想吃得像样、但没有锅」的中间地带——用一个已经成熟的工艺(非油炸 + 蒸煮复合)把一个没被满足的需求(口感)补上。"},
  290. ],
  291. },
  292. {
  293. "id": "ch5", "no": 5, "stage": "S5", "en": "POSITIONING", "title": "定位推导",
  294. "subtitle": "心智阶梯 → 差异化锚点 → 一句话定位",
  295. "lead": "深夜的宿舍里,用户想起吃一碗面时,脑子里排在前面的依次是谁?我们要找的是这个序列里空着的那一格。",
  296. "sections": [
  297. {"id": "s5-1", "no": "5.1", "title": "心智阶梯 · 用户决策序列", "kind": "ladder", "data": positioning},
  298. {"id": "s5-2", "no": "5.2", "title": "差异化锚点", "kind": "table",
  299. "data": {"cols": ["对标", "它的弱点", "我们的说法"], "rows": positioning["axis"]}},
  300. {"id": "s5-3", "no": "5.3", "title": "定位陈述", "kind": "statement", "data": positioning},
  301. ],
  302. },
  303. {
  304. "id": "ch6", "no": 6, "stage": "S5", "en": "ACTION PLAN", "title": "行动蓝图",
  305. "subtitle": "P0 立即 / P1 三个月验证 / 数据缺口",
  306. "lead": "从结论到落地的最短路径:两周内能动的先动,三个月内用数据验证口感与场景,缺的数据写清楚下一轮怎么补。",
  307. "sections": [
  308. {"id": "s6-1", "no": "6.1", "title": "P0 + P1 行动清单", "kind": "table", "data": {
  309. "cols": ["编号", "行动项", "负责人", "期限", "具体内容", "优先级"], "rows": actions}},
  310. {"id": "s6-2", "no": "6.2", "title": "三个月验证 · 决策节点", "kind": "milestones", "data": milestones},
  311. {"id": "s6-3", "no": "6.3", "title": "数据缺口 × 下一轮采集", "kind": "table", "data": {
  312. "cols": ["缺口", "当前状态", "需要什么", "影响决策", "优先级"], "rows": gaps}},
  313. ],
  314. },
  315. ],
  316. "evidence": E,
  317. }
  318. def find_target(arts):
  319. for a in arts:
  320. if a.get("title") == TARGET_TITLE_KEYWORD:
  321. return a
  322. raise SystemExit(f"找不到承载报告的成果:{TARGET_TITLE_KEYWORD}(库里现有 {len(arts)} 条)")
  323. def art_ids_by_owner(arts):
  324. """把报告里的结论/证据挂到真实存在的成果上(找不到就是 None,看板会如实显示)。
  325. 优先选过程层、有出身的成果;同一个人有多条时,优先带原文的,再按时间。"""
  326. def rank(a):
  327. return (0 if a.get("layer") != "record" else 1,
  328. 0 if a.get("sources") else 1,
  329. a.get("createdAt") or "")
  330. pool = sorted(arts, key=rank)
  331. def first(owner, stage_prefix=None):
  332. for a in pool:
  333. if a.get("ownerName") == owner and (not stage_prefix or str(a.get("stage", "")).startswith(stage_prefix)):
  334. return a["objectId"]
  335. return None
  336. return {
  337. "zw_report": find_target(arts)["objectId"],
  338. "zw_pain": first("张伟", "S1"), "zw_opp": first("张伟", "S1"),
  339. "ln_pain": first("李娜", "S2"), "ln_report": first("李娜", "S2"),
  340. "wf_pain": first("王芳", "S3"), "wf_opp": first("王芳", "S3"),
  341. "hm_pain": first("陈静"), "hm_opp": first("陈静"),
  342. "jn_pain": first("赵磊"), "jn_report": first("赵磊"),
  343. "cj_opp": first("陈静", "S5"), "cj_pain": first("陈静", "S5"),
  344. "ly_opp": first("刘洋", "S4"), "ly_report": first("刘洋", "S4"),
  345. "zt_record": next((a["objectId"] for a in arts if a.get("layer") == "record"
  346. and "老年人" in a.get("title", "")), None),
  347. }
  348. def main():
  349. if not MK and not DRY:
  350. sys.exit("PARSE_MASTER_KEY 未设置")
  351. arts = fetch("KfArtifact") if MK else []
  352. target = find_target(arts)
  353. report = build(art_ids_by_owner(arts))
  354. body = json.dumps(report, ensure_ascii=False)
  355. if DRY:
  356. print(json.dumps({"target": target["objectId"], "title": target["title"],
  357. "bytes": len(body.encode()), "chapters": len(report["chapters"])}, ensure_ascii=False))
  358. return
  359. r = call("PUT", f"/classes/KfArtifact/{target['objectId']}", {"report": report})
  360. print("report written:", target["title"], "→", target["objectId"],
  361. "| bytes", len(body.encode()), "| chapters", len(report["chapters"]),
  362. "| ok" if "updatedAt" in r else f"| ERROR {r}")
  363. if __name__ == "__main__":
  364. main()