| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395 |
- """seed_report.py —— 给一条成果写入一份完整的示例报告(康师傅拌面 · 按江中猴菇饮骨架)。
- 报告存在 KfArtifact.report(结构化 JSON),看板 js/views/report.js 负责渲染。
- 本脚本可重复执行:每次覆盖同一条成果的 report 字段,不改动其他人的数据。
- python3 tools/seed_report.py # 写入(需要 PARSE_MASTER_KEY)
- python3 tools/seed_report.py --dry-run # 只打印摘要,不写库
- 结构(六章对应五个阶段,见 README「报告数据结构」):
- cover 封面 → chapters[ch1..ch6](每章 lead + 若干 section)+ evidence 证据链
- evidence[conclusionId] = { claim, ownerCount, quotes[{artId, author, text, ref}], derivedFrom }
- derivedFrom 引用现存 KfArtifact 的 objectId:结论旁点「证据链」能看到是哪几条成果撑起来的。
- 看板会把结论挂到它引用的成果上:在列表里点开那些成果,能看到「支撑了哪条结论」。
- 字段一律是数据,不是 HTML:渲染端负责排版与转义,避免把一段 HTML 塞进库里。
- 中文正文里的引用一律用「」,避免和外层字符串引号打架。
- """
- import json
- import os
- import sys
- import urllib.error
- import urllib.request
- BASE = "https://kangshifu.fmode.cn/parse"
- APP_ID = "FKieRbIoGt_main"
- MK = os.environ.get("PARSE_MASTER_KEY", "")
- DRY = "--dry-run" in sys.argv
- H = {"X-Parse-Application-Id": APP_ID, "Content-Type": "application/json"}
- if MK:
- H["X-Parse-Master-Key"] = MK
- # 选哪条成果承载示例报告:张伟 S1 的品类竞品报告(已有 project / ownerSession 出身)
- TARGET_TITLE_KEYWORD = "夜宵场景低卡高蛋白拌面 - competitor_report"
- def call(method, path, body=None):
- data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
- req = urllib.request.Request(BASE + path, data=data, headers=H, method=method)
- with urllib.request.urlopen(req, timeout=30) as r:
- return json.loads(r.read().decode() or "{}")
- def fetch(cls):
- return call("GET", f"/classes/{cls}?limit=1000&order=createdAt").get("results", [])
- def q(art_id, author, text, ref, likes=None):
- o = {"artId": art_id, "author": author, "text": text, "ref": ref}
- if likes is not None:
- o["likes"] = likes
- return o
- def build(ids):
- """ids: 把示例报告里用到的结论挂到真实成果 objectId 上(缺失为 None,看板显示「证据未接入」)。"""
- E = {
- "c-texture": {
- "claim": "「太软没嚼劲」是拌面复购的第一杀手,且被不同成员独立提到",
- "ownerCount": 4,
- "derivedFrom": [ids.get("zw_pain"), ids.get("ln_pain"), ids.get("wf_opp")],
- "quotes": [
- q(ids.get("zw_pain"), "夜宵党_312", "泡完就坨,没有一碗速食拌面该有的嚼劲,第二次就不会买了", "抖音评论", 412),
- q(ids.get("ln_pain"), "上班族_077", "面饼太软,拌酱以后像糊糊,想要那种手擀面的筋道", "小红书笔记", 238),
- q(ids.get("wf_opp"), "学生党_156", "宿舍没有锅,只能泡,泡出来的口感比煮的差一截", "B站评论", 96),
- q(None, "口味控_044", "康师傅的拌面面饼偏软,拌的时候一直断", "电商差评", 0),
- ],
- },
- "c-sauce": {
- "claim": "酱包集中在「麻酱 / 香辣」两类,地域风味是空档",
- "ownerCount": 3,
- "derivedFrom": [ids.get("jn_pain"), ids.get("wf_pain"), ids.get("hm_pain")],
- "quotes": [
- q(ids.get("jn_pain"), "地域党_208", "想吃川味,但是只有辣——麻香和香辣是两种东西", "小红书笔记", 166),
- q(None, "江浙沪_021", "拌面全是麻酱和辣,清淡口味的没得选", "电商评论", 0),
- ],
- },
- "c-price": {
- "claim": "学生与加班人群对份量敏感,「吃不饱」比「难吃」更容易被差评",
- "ownerCount": 2,
- "derivedFrom": [ids.get("wf_opp"), ids.get("ln_pain")],
- "quotes": [
- q(None, "大胃王_553", "一包根本吃不饱,加一包又要再冲一次,很麻烦", "电商评论", 0),
- q(None, "加班党_119", "十块钱的拌面,吃完还是很饿,不如点外卖", "抖音评论", 302),
- ],
- },
- "c-portable": {
- "claim": "露营 / 宿舍场景的核心矛盾是「没有锅也要有锅气」",
- "ownerCount": 2,
- "derivedFrom": [ids.get("cj_opp"), ids.get("cj_pain")],
- "quotes": [
- q(None, "露营_031", "露营带的都是泡面,想要拌面,但得煮,太麻烦", "小红书笔记", 521),
- q(None, "户外_882", "户外最简单就是开水,拌面要倒掉面汤,步骤多一步就不想买", "抖音评论", 143),
- ],
- },
- "c-noodle": {
- "claim": "地域风味(西北 / 西南 / 东北)在年轻人里的搜索热度高于现有 SKU 覆盖",
- "ownerCount": 3,
- "derivedFrom": [ids.get("zw_report"), ids.get("ly_opp"), ids.get("hm_pain")],
- "quotes": [
- q(None, "风味_1", "重庆小面的麻辣鲜香,速食里基本找不到", "小红书笔记", 389),
- q(None, "风味_2", "油泼面那种宽面,速食做不出来", "抖音评论", 274),
- q(None, "风味_3", "东北的酸菜拌面,速食没有", "微博话题", 0),
- ],
- },
- "c-plain": {
- "claim": "「不将就」的日常一餐是最大增量,但用户不愿意为此多花超过 3 元",
- "ownerCount": 2,
- "derivedFrom": [ids.get("ln_report"), ids.get("hm_opp")],
- "quotes": [
- q(None, "白领_267", "加班到十点,想吃口热的,又不想吃泡面的汤汤水水", "小红书笔记", 198),
- q(None, "白领_341", "更愿意为口感多付,但超过十五块就点外卖了", "B站评论", 87),
- ],
- },
- # 反面证据:一条被标成记录层的重复/推演成果,拿它说明「我们排除了什么」
- "c-weak": {
- "claim": "「老年人更爱汤面」这类推演,没有原文支撑就不能作为立项依据",
- "ownerCount": 1,
- "derivedFrom": [ids.get("zt_record")],
- "quotes": [],
- },
- }
- funnel = [
- {"stage": "S1", "name": "品类池", "value": 46, "unit": "", "scope": "国内速食面品类(拌面 / 泡面 / 鲜面 / 自热)",
- "note": "先把品类地图铺开,确定我们在哪条线上竞争。"},
- {"stage": "S2", "name": "目标人群", "value": 2.7, "unit": "亿", "scope": "18–35 岁、每周至少吃一次速食面条的一线到三线城市人群",
- "note": "用 VOC 原声过滤掉只吃外卖和只吃高端鲜面两端。"},
- {"stage": "S3", "name": "拌面细分市场", "value": 3000, "unit": "万人", "scope": "愿意把速食面当一顿正餐而不是垫一口的人",
- "note": "S3 机会识别:这里才是「不将就」需求的集中区。"},
- {"stage": "S4", "name": "可切入场景", "value": 900, "unit": "万人", "scope": "加班 / 宿舍 / 露营 三类「没有锅」的场景",
- "note": "S4 验证:三个场景的用户结构、付费意愿与竞品密度分别打分。"},
- {"stage": "S5", "name": "首推机会", "value": 180, "unit": "万人", "scope": "「不需要锅也能吃到锅气」的即食鲜拌面",
- "note": "S5 卖点设计:把「锅气」做成一句能记住的话。"},
- ]
- xray = {
- "cols": ["场景", "用户此刻的选择", "未满足需求(VOC 原声)", "VOC 条数", "机会"],
- "rows": [
- ["加班夜宵", [["泡面(汤面)", 61], ["外卖拌饭", 24], ["自热锅", 9]],
- ["面饼泡后偏软、没有嚼劲", "汤多、吃完撑但不满足"], 486, "高", "c-texture"],
- ["宿舍速食", [["泡面", 55], ["干拌面(麻酱/香辣)", 33]],
- ["只有麻酱和辣两种口味", "一包吃不饱、加一包又麻烦"], 372, "高", "c-sauce"],
- ["办公室午餐", [["便利店盒饭", 40], ["外卖", 38], ["速食拌面", 12]],
- ["拌好以后坨掉、没法边工作边吃", "等热水 3 分钟也被嫌慢"], 258, "中", "c-plain"],
- ["露营 / 户外", [["泡面", 48], ["干粮", 30]],
- ["没有锅也能有锅气", "拌面得倒面汤,多一步就不买"], 193, "中", "c-portable"],
- ["家庭囤货", [["大包装泡面", 70], ["挂面", 18]],
- ["一家人吃时口味不够分", "只看价格、不看口感"], 311, "低", "c-price"],
- ],
- }
- fit = {
- "can": [
- "面饼工艺:非油炸 + 蒸煮线的复合工艺已经是现有产线能力",
- "酱包研发:川、湘、西北三地的调味样已经在研发中心储备",
- "渠道:铺到 KA 与便利店,即食场景天然可达",
- "品牌:在速食面心智里有默认信任,降低尝试成本",
- ],
- "cannot": [
- "对标现煮手擀面的筋道——速食工艺做不到,宣称「手擀」是虚假宣传",
- "做成「不用水」的常温即食鲜面——目前保鲜与货架期都不支持",
- "承诺「吃饱」——份量上去之后成本与价格带冲突,不能两头都要",
- ],
- "transferCols": ["成功要素", "来源", "能否迁移到拌面", "怎么落地"],
- "transfer": [
- ["固定场景", "泡面(加班夜宵 / 宿舍)", "✅ 可迁移", "把「夜宵」写进包装正面,做 21:00–1:00 的场景标签"],
- ["大口味心智", "红烧牛肉面", "✅ 可迁移", "用「红烧」做第一支口味的锚点,降低认知成本"],
- ["低价尝鲜", "袋装泡面 2.5 元", "⚠️ 部分迁移", "只做小规格尝鲜装,主推规格定价 8–10 元"],
- ["煮食依赖", "鲜面 / 挂面", "❌ 不可迁移", "这是速食拌面必须绕开的红线"],
- ],
- "redlineCols": ["失败因子", "风险", "在拌面上会怎么发生"],
- "redline": [
- ["产品反效果", "极高", "酱太咸、口感坨掉 → 差评集中"],
- ["宣称与实际体验矛盾", "高", "宣称「锅气」但泡出来是糊的 → 信任崩塌"],
- ["品类联想错位", "中", "把拌面当零食卖 → 复购低"],
- ["价格高于外卖替代", "中", "定价超过 15 元 → 用户直接用外卖替代"],
- ],
- }
- positioning = {
- "ladder": [
- ["#1", "外卖 / 现煮面", "愿意花钱,但要像一顿饭"],
- ["#2", "瓶装泡面", "便宜、快、默认选择"],
- ["#3", "干拌面(麻酱 / 香辣)", "偶尔换个口味"],
- ["#4", "自热锅", "周末 / 露营的重口味方案"],
- ["#5", "⭐ 空着的位置", "想要不将就,但没有锅"],
- ],
- "statement": "不用锅,也能拌出面馆的口感",
- "for_who": "加班到深夜、宿舍里只有一壶开水、露营车上只有一个保温壶的人",
- "axis": [
- ["vs 泡面", "泡着吃是软塌的根源", "我们是拌的,口感由产品本身决定"],
- ["vs 外卖拌饭", "外卖是一顿饭,但我们更快、更热", "夜宵 3 分钟,比等外卖快"],
- ["vs 自热锅", "自热锅是重口味的仪式感,我们的场景更日常", "办公室 / 宿舍也能用"],
- ],
- "alt": ["B 案:「深夜的面馆,三分钟到桌」", "C 案:「宿舍里的一碗热拌面」(注意:不得使用手擀字样)"],
- }
- actions = [
- ["P0-1", "面饼工艺样机", "研发中心", "2 周", "非油炸 + 蒸煮复合工艺,目标「泡 4 分钟后仍有嚼劲」,与现有面饼盲测对比", "P0"],
- ["P0-2", "第一支口味锁定", "产品部", "2 周", "以红烧为锚,川味麻辣与西北油泼两个备选,各 200 份盲测", "P0"],
- ["P0-3", "场景包装", "品牌部", "3 周", "包装正面写场景(加班 / 宿舍 / 露营),背面写「不用锅」的冲泡步骤", "P0"],
- ["P1-1", "便利店夜宵端架测试", "渠道部", "1 个月", "选 50 家 24h 便利店做端架,观察 21 点后的动销", "P1"],
- ["P1-2", "露营渠道试水", "渠道部", "2 个月", "与户外品牌联名小规格装,验证「多一步就不买」", "P1"],
- ["P1-3", "地域风味扩展评估", "产品部", "3 个月", "基于 S1 竞品报告的地域风味搜索数据,评估第二支口味", "P1"],
- ]
- milestones = [
- ["M1", "口感验证",
- ["盲测中「泡后仍有嚼劲」的通过率 ≥ 60%", "与现煮面盲测对比,偏好度 ≥ 40%"],
- "通过率 < 40% → 回到工艺选择;< 25% → 放弃「不要锅」概念"],
- ["M2", "场景验证",
- ["50 家便利店夜宵时段动销率", "露营联合装复购率"],
- "动销率 < 10% → 调整端架与包装;仍无起色 → 换场景"],
- ["M3", "复购验证",
- ["首月复购率 ≥ 15%", "NPS ≥ 30"],
- "复购率 < 8% → 口感仍是问题;NPS < 20 → 定位偏差"],
- ]
- gaps = [
- ["现有产品的真实口感差距", "只有二手评论", "面饼含水量 / 复水时间参数 + 竞品盲测", "决定工艺路线", "高"],
- ["露营场景的实际购买路径", "S4 只做了问卷推演", "3 个营地的现场观察", "决定联名规格", "中"],
- ["外卖价格带的替代阈值", "没有价格敏感度测试", "10 / 12 / 15 元三档价格测试", "决定定价", "高"],
- ]
- return {
- "version": 1,
- "title": "康师傅即食鲜拌面 · 场景与口感机会洞察",
- "subtitle": "从泡着吃到拌着吃:5 大场景 × 现有产品 × 未满足需求",
- "method": "VOC 原声提取 × 场景拆解 × 竞品矩阵 × 机会验证评分(S1 品类 → S5 卖点)",
- "sources": "抖音 186 条视频 / 小红书 402 篇笔记 / 电商评论 1,930 条 / 便利店端架观察 12 家",
- "pipeline": [{"label": "原始 VOC 采集", "value": 2727}, {"label": "去噪去重", "value": 1190},
- {"label": "精准 VOC", "value": 730}],
- "filterRate": "73%",
- "generatedAt": "2026-10-29T10:20:00.000Z",
- "structure": "封面 → 全景漏斗 → 嵌套洞察 → 痛声地图 → 产品适配 → 定位推导 → 行动蓝图",
- "kpis": [
- {"value": 2727, "unit": "条", "label": "原始 VOC", "note": "5 个平台、23 个关键词,30 天抓取"},
- {"value": 730, "unit": "条", "label": "精准 VOC", "note": "过滤率 73%,剔除广告、重复与无场景评论"},
- {"value": 5, "unit": "个", "label": "核心场景", "note": "加班夜宵 / 宿舍 / 办公室 / 露营 / 家庭囤货"},
- {"value": 2.7, "unit": "亿人", "label": "目标人群", "note": "18–35 岁,每周至少一次速食面条的城市人群"},
- ],
- "executive": [
- "拌面的问题不是没人吃,是吃得将就——用户想吃一碗像饭馆的拌面,但没有锅。",
- "「软塌 / 坨掉」是复购第一杀手,出现频次高于「难吃」;三份独立成果都指向同一件事。",
- "口味集中在麻酱与香辣两类,川味麻辣、西北油泼在搜索端有热度、产品端是空白。",
- "露营是「知道痛但还没买」的场景:多一步(倒面汤)就会损失一半潜在用户。",
- ],
- "chapters": [
- {
- "id": "ch1", "no": 1, "stage": "S1", "en": "MARKET FUNNEL", "title": "全景漏斗",
- "subtitle": "46 个品类 → 2.7 亿人 → 3000 万人 → 900 万人 → 180 万人",
- "lead": "把速食面这个大品类一路收窄到「没有锅、但想吃一碗好面」的那 180 万人——先看清我们是在几亿人里切多大一块。",
- "sections": [
- {"id": "s1-1", "no": "1.1", "title": "五级漏斗 · 从品类池到首推机会", "kind": "funnel", "data": funnel},
- {"id": "s1-2", "no": "1.2", "title": "品类全景 · 三条产品线的现状", "kind": "para",
- "text": "国内速食面大致分三线:瓶装泡面(占比最大、增长停滞)、干拌面(近两年增长最快、但口味高度同质)、鲜面 / 自热(价格高、场景窄)。拌面是唯一一条「增长 + 同质化」同时成立的线——这意味着机会不在有没有产品,而在谁先把口感做出区隔。"},
- ],
- },
- {
- "id": "ch2", "no": 2, "stage": "S2", "en": "NESTED INSIGHT", "title": "嵌套洞察",
- "subtitle": "5 大场景 × 现有产品选择 × 未满足需求 × 机会",
- "lead": "用户不是按品类买面的,是按「此刻在哪儿、有没有锅、饿到什么程度」买的——把这三个坐标叠起来,才看得到空白。",
- "sections": [
- {"id": "s2-1", "no": "2.1", "title": "5 大场景 × 现有产品 × 未满足需求", "kind": "matrix", "data": xray,
- "note": "VOC 条数为该场景下相关原声条数;末列指后文产品适配章节对应的结论。"},
- {"id": "s2-2", "no": "2.2", "title": "三条最锋利的结论", "kind": "findings", "data": [
- {"id": "f-1", "conclusion": "「太软没嚼劲」是第一杀手",
- "attitude": "口感不是加分项,是及格线。口感不合格的拌面,再怎么讲口味都不会复购。", "evidence": "c-texture"},
- {"id": "f-2", "conclusion": "口味空档在地域,不在辣度",
- "attitude": "再多一个更辣的口味没有意义;川味的麻、西北的油泼,才是搜索端在找、货架上没有的东西。", "evidence": "c-sauce"},
- {"id": "f-3", "conclusion": "露营场景「多一步就不买」",
- "attitude": "户外用户愿意为像样的一餐多付钱,但不愿意多带一口锅、多倒一次水。", "evidence": "c-portable"},
- ]},
- ],
- },
- {
- "id": "ch3", "no": 3, "stage": "S2", "en": "PAIN MAP", "title": "痛声地图",
- "subtitle": "每一条差评 → 是哪类用户 → 有没有原文 → 能不能立项",
- "lead": "把 730 条精准 VOC 里真正带原文的痛声挑出来,钉在场景上——没有原文的痛声只能当线索,不能当依据。",
- "sections": [
- {"id": "s3-1", "no": "3.1", "title": "痛点 × 场景 × 严重度", "kind": "table", "data": {
- "cols": ["痛点", "集中场景", "出现条数", "严重度", "能不能立项"],
- "rows": [
- ["面饼泡后偏软、坨掉", "加班夜宵 / 宿舍", 214, "严重", "能:工艺问题,可解"],
- ["只有麻酱 / 香辣两种口味", "宿舍 / 家庭", 168, "严重", "能:研发已有地域样"],
- ["一包吃不饱", "宿舍 / 加班", 132, "中", "有条件:需解决成本"],
- ["冲泡步骤多、要倒面汤", "露营 / 户外", 97, "中", "能:改包装结构"],
- ["酱太咸 / 太油", "全场景", 76, "中", "能:调味迭代"],
- ]}},
- {"id": "s3-2", "no": "3.2", "title": "反面证据:不能拿推演当依据", "kind": "findings", "data": [
- {"id": "f-4", "conclusion": "「老年人更爱汤面」只是一条推断",
- "attitude": "这条推断掉进了记录层:没有原文、缺出身,不能用来指导立项。把它留在证据链里,是为了说明我们排除了什么。",
- "evidence": "c-weak"},
- ]},
- ],
- },
- {
- "id": "ch4", "no": 4, "stage": "S4", "en": "PRODUCT FIT", "title": "产品适配",
- "subtitle": "能做到的 × 做不到的 × 可迁移的 × 红线",
- "lead": "不吹不黑——康师傅在速食面上真正的能力边界在哪?哪些是能把泡面的成功搬过来的,哪些是碰了就要翻车的。",
- "sections": [
- {"id": "s4-1", "no": "4.1", "title": "能做到的 vs 做不到的", "kind": "two", "data": fit},
- {"id": "s4-2", "no": "4.2", "title": "成功因子可迁移 vs 失败的红线", "kind": "fit", "data": fit},
- {"id": "s4-3", "no": "4.3", "title": "机会评分 · 需求强度 × 竞争烈度 × 我方能力", "kind": "score", "data": [
- ["不需要锅的鲜拌面", 9, 4, 8, 84, "✅ 首推"],
- ["川味麻辣拌面", 8, 6, 9, 71, "✅ 备选口味"],
- ["露营联名小装", 6, 3, 6, 58, "⏳ 小规模验证"],
- ["对标外卖的正餐拌面", 7, 9, 4, 41, "❌ 红海,不进"],
- ], "note": "评分 = 需求强度×5 + 我方能力×3 − 竞争烈度×2,满分 100;只用于排序,不作为决策依据。"},
- {"id": "s4-4", "no": "4.4", "title": "精准插入点", "kind": "para",
- "text": "结论:不进更便宜的价格战,也不进像外卖正餐的红海,而是切「想吃得像样、但没有锅」的中间地带——用一个已经成熟的工艺(非油炸 + 蒸煮复合)把一个没被满足的需求(口感)补上。"},
- ],
- },
- {
- "id": "ch5", "no": 5, "stage": "S5", "en": "POSITIONING", "title": "定位推导",
- "subtitle": "心智阶梯 → 差异化锚点 → 一句话定位",
- "lead": "深夜的宿舍里,用户想起吃一碗面时,脑子里排在前面的依次是谁?我们要找的是这个序列里空着的那一格。",
- "sections": [
- {"id": "s5-1", "no": "5.1", "title": "心智阶梯 · 用户决策序列", "kind": "ladder", "data": positioning},
- {"id": "s5-2", "no": "5.2", "title": "差异化锚点", "kind": "table",
- "data": {"cols": ["对标", "它的弱点", "我们的说法"], "rows": positioning["axis"]}},
- {"id": "s5-3", "no": "5.3", "title": "定位陈述", "kind": "statement", "data": positioning},
- ],
- },
- {
- "id": "ch6", "no": 6, "stage": "S5", "en": "ACTION PLAN", "title": "行动蓝图",
- "subtitle": "P0 立即 / P1 三个月验证 / 数据缺口",
- "lead": "从结论到落地的最短路径:两周内能动的先动,三个月内用数据验证口感与场景,缺的数据写清楚下一轮怎么补。",
- "sections": [
- {"id": "s6-1", "no": "6.1", "title": "P0 + P1 行动清单", "kind": "table", "data": {
- "cols": ["编号", "行动项", "负责人", "期限", "具体内容", "优先级"], "rows": actions}},
- {"id": "s6-2", "no": "6.2", "title": "三个月验证 · 决策节点", "kind": "milestones", "data": milestones},
- {"id": "s6-3", "no": "6.3", "title": "数据缺口 × 下一轮采集", "kind": "table", "data": {
- "cols": ["缺口", "当前状态", "需要什么", "影响决策", "优先级"], "rows": gaps}},
- ],
- },
- ],
- "evidence": E,
- }
- def find_target(arts):
- for a in arts:
- if a.get("title") == TARGET_TITLE_KEYWORD:
- return a
- raise SystemExit(f"找不到承载报告的成果:{TARGET_TITLE_KEYWORD}(库里现有 {len(arts)} 条)")
- def art_ids_by_owner(arts):
- """把报告里的结论/证据挂到真实存在的成果上(找不到就是 None,看板会如实显示)。
- 优先选过程层、有出身的成果;同一个人有多条时,优先带原文的,再按时间。"""
- def rank(a):
- return (0 if a.get("layer") != "record" else 1,
- 0 if a.get("sources") else 1,
- a.get("createdAt") or "")
- pool = sorted(arts, key=rank)
- def first(owner, stage_prefix=None):
- for a in pool:
- if a.get("ownerName") == owner and (not stage_prefix or str(a.get("stage", "")).startswith(stage_prefix)):
- return a["objectId"]
- return None
- return {
- "zw_report": find_target(arts)["objectId"],
- "zw_pain": first("张伟", "S1"), "zw_opp": first("张伟", "S1"),
- "ln_pain": first("李娜", "S2"), "ln_report": first("李娜", "S2"),
- "wf_pain": first("王芳", "S3"), "wf_opp": first("王芳", "S3"),
- "hm_pain": first("陈静"), "hm_opp": first("陈静"),
- "jn_pain": first("赵磊"), "jn_report": first("赵磊"),
- "cj_opp": first("陈静", "S5"), "cj_pain": first("陈静", "S5"),
- "ly_opp": first("刘洋", "S4"), "ly_report": first("刘洋", "S4"),
- "zt_record": next((a["objectId"] for a in arts if a.get("layer") == "record"
- and "老年人" in a.get("title", "")), None),
- }
- def main():
- if not MK and not DRY:
- sys.exit("PARSE_MASTER_KEY 未设置")
- arts = fetch("KfArtifact") if MK else []
- target = find_target(arts)
- report = build(art_ids_by_owner(arts))
- body = json.dumps(report, ensure_ascii=False)
- if DRY:
- print(json.dumps({"target": target["objectId"], "title": target["title"],
- "bytes": len(body.encode()), "chapters": len(report["chapters"])}, ensure_ascii=False))
- return
- r = call("PUT", f"/classes/KfArtifact/{target['objectId']}", {"report": report})
- print("report written:", target["title"], "→", target["objectId"],
- "| bytes", len(body.encode()), "| chapters", len(report["chapters"]),
- "| ok" if "updatedAt" in r else f"| ERROR {r}")
- if __name__ == "__main__":
- main()
|