| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489 |
- """seed_opportunities.py —— 造机会卡示例数据(KfOpportunity)+ 上传元数据 + 一条"卡住"的现场数据。
- 为什么单独一个类:机会卡有独立生命周期(评分、状态、决策记录、优先级),跟"成果"不是一回事。
- 理由见 README「为什么新建 KfOpportunity」。
- 可重复执行(幂等):
- - 每张卡带 seedKey;已存在同 seedKey 的卡:只补缺失的上传元数据 / 关联成果 / 历史评分,不覆盖人工改过的 status/priority/decision;
- - 评分历史 [seed:xxx] 按 seedKey 去重,不会越跑越多;
- - 现场演示用的卡住数据同样按 seedKey 幂等。
- PARSE_MASTER_KEY=... python3 tools/seed_opportunities.py # 写入
- PARSE_MASTER_KEY=... python3 tools/seed_opportunities.py --dry-run # 只看要写什么
- PARSE_MASTER_KEY=... python3 tools/seed_opportunities.py --reset-scores # 把示例评分恢复到初始值
- 凭据只从环境变量读,不落盘。
- """
- import json
- import os
- import sys
- import urllib.error
- import urllib.request
- from datetime import datetime, timedelta, timezone
- BASE = "https://kangshifu.fmode.cn/parse"
- APP_ID = "FKieRbIoGt_main"
- MK = os.environ.get("PARSE_MASTER_KEY", "")
- DRY = "--dry-run" in sys.argv
- RESET_SCORES = "--reset-scores" in sys.argv
- H = {"X-Parse-Application-Id": APP_ID, "Content-Type": "application/json"}
- if MK:
- H["X-Parse-Master-Key"] = MK
- NOW = datetime.now(timezone.utc)
- def iso(dt):
- return dt.strftime("%Y-%m-%dT%H:%M:%S.") + "%03dZ" % (dt.microsecond // 1000)
- def hours_ago(h):
- return iso(NOW - timedelta(hours=h))
- 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)
- try:
- with urllib.request.urlopen(req, timeout=30) as r:
- return json.loads(r.read().decode() or "{}")
- except urllib.error.HTTPError as e:
- return {"_error": e.code, "_body": e.read().decode()[:400]}
- def fetch(cls):
- return call("GET", f"/classes/{cls}?limit=1000&order=createdAt").get("results", [])
- # ---------------------------------------------------------------- 上传元数据
- # 所有"被上传的东西"都带这五个字段;老数据补齐 uploadedByKey 的值由这里统一推导。
- UPLOAD_METHODS = {"session": "会话上传", "skill": "Skill 上传", "manual": "手工录入"}
- def upload_meta(person, key, agent_name, agent_id, method, at, session=None):
- m = {
- "uploadedBy": person,
- "uploadedByKey": key,
- "agentName": agent_name,
- "agentId": agent_id,
- "uploadMethod": method,
- "uploadedAt": at,
- }
- if session:
- m["sourceSession"] = session
- return m
- MAIN = ("康师傅产品创新助手", "agent-main-01") # 主分身:学员在会话里把想法交给它
- RESEARCH = ("品类调研 Agent", "agent-research-02") # 调研 Agent:自动抓 VOC / 竞品
- CHECK = ("机会评估 Agent", "agent-check-03") # 评估 Agent:按评分模型打分
- DESIGN = ("概念设计 Agent", "agent-design-04") # 设计 Agent:把机会变成方案
- # ---------------------------------------------------------------- 机会卡
- # 每张卡都挂到库里真实存在的成果(KfArtifact / KfProject),保证"点开看报告/证据"有东西。
- # scores:[评分人, 分数, 评语, 几小时前, seedKey]
- CARDS = [
- {
- "seedKey": "seed-opp-1",
- "title": "夜宵场景低卡高蛋白拌面",
- "oneLiner": "加班到十点想吃饱又怕胖:低卡高蛋白 + 1 分钟即食,5 个夜宵场景里 3 个需求未被满足",
- "stage": "S3 机会识别",
- "category": "拌面",
- "priority": "S",
- "status": "candidate",
- "evidenceLevel": "high",
- "quoteCount": 12,
- "citeCount": 8,
- "upload": upload_meta("张伟", "sess-01", *MAIN, "session", hours_ago(6), session="sess-01"),
- "linkedProject": "QFvrQsUPyD",
- "linkedArtifacts": ["KHEScCO5dT", "5icUD1vUVN", "BjBza4kr2U"],
- "decision": {
- "type": "concept", "label": "进入概念验证",
- "reason": "S3 阶段 5 位成员独立提到夜宵场景的「饱腹 + 低卡」矛盾;已有 12 条带原文的 VOC 支撑。建议 S5 做一次概念测试。",
- "by": "张伟", "at": hours_ago(4),
- },
- "scores": [
- ("李娜", 86, "场景真实,VOC 密度高,可以做", 9),
- ("吴敏", 90, "和现有夜宵 SKU 不冲突,值得立项", 7),
- ("周涛", 84, "高蛋白卖点需要工艺支持,先验证", 5),
- ("评价 Agent", 88, "人群规模 × 需求强度 × 可行性 综合 88", 3),
- ],
- },
- {
- "seedKey": "seed-opp-2",
- "title": "办公室 3 分钟速食拌面",
- "oneLiner": "午休只有 10 分钟、不想吃汤汤水水:免煮、不冒汤、可带去办公室的拌面",
- "stage": "S4 验证评估",
- "category": "拌面",
- "priority": "A",
- "status": "approved",
- "evidenceLevel": "high",
- "quoteCount": 9,
- "citeCount": 5,
- "upload": upload_meta("李娜", "sess-02", *MAIN, "session", hours_ago(30), session="sess-02"),
- "linkedProject": "PzNTNt7ttt",
- "linkedArtifacts": ["BjBza4kr2U", "yGwGJ9aPu6"],
- "decision": {
- "type": "approve", "label": "立项",
- "reason": "S4 验证完成:三家竞品均无免汤方案,目标人群 2.7 亿中约 18% 处于该场景;评分模型 82 分,过线。",
- "by": "刘洋", "at": hours_ago(20),
- },
- "scores": [
- ("刘洋", 84, "验证数据齐,建议立项", 26),
- ("王芳", 80, "价格带要卡住 8 元", 22),
- ("评价 Agent", 82, "需求 85 / 竞争 78 / 可行性 83", 20),
- ],
- },
- {
- "seedKey": "seed-opp-3",
- "title": "地域风味麻酱拌面",
- "oneLiner": "现有拌面酱包集中在麻酱 / 香辣两类;西北油泼、西南麻辣是搜索热但 SKU 空档",
- "stage": "S4 验证评估",
- "category": "拌面",
- "priority": "A",
- "status": "candidate",
- "evidenceLevel": "medium",
- "quoteCount": 6,
- "citeCount": 3,
- "upload": upload_meta("刘洋", "sess-04", *RESEARCH, "skill", hours_ago(52)),
- "linkedProject": "",
- "linkedArtifacts": ["QVrfdasAkg", "C6AFWrqyTQ"],
- "decision": {
- "type": "hold", "label": "暂缓(补原文)",
- "reason": "方向认可,但支撑它的 6 条记录里只有 3 条带原文,销量数据待调研 Agent 回传。补齐前不排产。",
- "by": "赵磊", "at": hours_ago(40),
- },
- "scores": [
- ("赵磊", 72, "方向对,证据不够", 45),
- ("陈静", 78, "和夜宵低卡可以合并做双口味", 38),
- ("评价 Agent", 75, "证据强度拖后腿", 36),
- ],
- },
- {
- "seedKey": "seed-opp-4",
- "title": "露营便携免煮拌面",
- "oneLiner": "露营带的都是泡面,想吃拌面又得煮:冷水泡 8 分钟、自带拌酱的免煮方案",
- "stage": "S5 场景与卖点设计",
- "category": "杯面",
- "priority": "S",
- "status": "candidate",
- "evidenceLevel": "medium",
- "quoteCount": 7,
- "citeCount": 4,
- "upload": upload_meta("陈静", "sess-05", *DESIGN, "skill", hours_ago(2)),
- "linkedProject": "",
- "linkedArtifacts": ["mdJAkc5u6X", "w5DDt2Qek4"],
- "decision": {
- "type": "concept", "label": "进入概念验证",
- "reason": "露营搜索量 2026 春夏环比 +41%;已有方案具备卖点抓手(「不烧水也能拌」)。等 S4 评分补齐。",
- "by": "陈静", "at": hours_ago(1.5),
- },
- "scores": [
- ("孙悦", 79, "场景有意思,量可能不大", 8),
- ("郑凯", 81, "卖点很清晰,可以做包装测试", 2),
- ],
- },
- {
- "seedKey": "seed-opp-5",
- "title": "健身人群高蛋白面",
- "oneLiner": "健身人群把速食面当「欺骗餐」,要的是每份 20g 以上蛋白、低脂、能算进宏量",
- "stage": "S2 用户需求与痛点",
- "category": "汤面",
- "priority": "B",
- "status": "rejected",
- "evidenceLevel": "low",
- "quoteCount": 2,
- "citeCount": 1,
- "upload": upload_meta("孙悦", "sess-07", *MAIN, "session", hours_ago(76), session="sess-07"),
- "linkedProject": "",
- "linkedArtifacts": ["0TufH1peEG", "rSqN03GPux", "WrWlJ1lSHC"],
- "decision": {
- "type": "reject", "label": "否决",
- "reason": "三条支撑记录里只有 2 条原文,且蛋白成本会把单价推到 15 元以上,和现有价格带冲突。留档,不做。",
- "by": "吴敏", "at": hours_ago(60),
- },
- "scores": [
- ("吴敏", 58, "成本算不过来", 70),
- ("周涛", 62, "需求真实但规模小", 66),
- ("评价 Agent", 55, "人群规模 ★★,可行性 ★★", 62),
- ],
- },
- {
- "seedKey": "seed-opp-6",
- "title": "银发低钠汤面",
- "oneLiner": "50 岁以上人群控盐诉求明确,现有方便面钠含量普遍 2000mg 以上,无低钠 SKU",
- "stage": "S1 品类市场分析",
- "category": "汤面",
- "priority": "B",
- "status": "candidate",
- "evidenceLevel": "medium",
- "quoteCount": 4,
- "citeCount": 2,
- "upload": upload_meta("赵磊", "sess-06", *MAIN, "manual", hours_ago(50)),
- "linkedProject": "",
- "linkedArtifacts": ["Lor4psRme0", "rvyuQ3985Q"],
- "decision": None,
- "scores": [
- ("赵磊", 70, "合规是卖点,工艺要改", 48),
- ],
- },
- {
- "seedKey": "seed-opp-7",
- "title": "夏日清爽凉拌面",
- "oneLiner": "夏季 6–8 月拌面搜索 +35%,但现有产品多为「热拌」,冷拌 / 冰镇吃法没有对应 SKU",
- "stage": "S3 机会识别",
- "category": "拌面",
- "priority": "A",
- "status": "candidate",
- "evidenceLevel": "medium",
- "quoteCount": 5,
- "citeCount": 3,
- "upload": upload_meta("周涛", "sess-08", *RESEARCH, "skill", hours_ago(0.4)),
- "linkedProject": "",
- "linkedArtifacts": ["9Mj4LV3a3P", "czLP44qeOl", "W42NYTiJFm"],
- "decision": {
- "type": "hold", "label": "指派评估",
- "reason": "季节性明确,需要 S4 用评分模型验证「冷藏口感」这一工艺风险。",
- "by": "吴敏", "at": hours_ago(0.2),
- },
- "scores": [
- ("周涛", 76, "季节性太强,看全年怎么讲", 0.3),
- ],
- },
- {
- "seedKey": "seed-opp-8",
- "title": "学生党实惠大份拌面",
- "oneLiner": "宿舍场景吃一碗不饱:同价位加量 30%,以「三包面饼」作为卖点",
- "stage": "S3 机会识别",
- "category": "炒面",
- "priority": "S",
- "status": "candidate",
- "evidenceLevel": "medium",
- "quoteCount": 5,
- "citeCount": 2,
- "upload": upload_meta("王芳", "sess-03", *MAIN, "session", hours_ago(28), session="sess-03"),
- "linkedProject": "gC9Ra0bnXf",
- "linkedArtifacts": ["OG7w0dxbja", "HmZG9wFEq7"],
- "decision": {
- "type": "concept", "label": "进入概念验证",
- "reason": "「吃不饱」在差评里频次高于「难吃」;加量 + 不涨价可行性待工艺评估。",
- "by": "王芳", "at": hours_ago(24),
- },
- "scores": [
- ("王芳", 91, "最接近真实痛点的一张卡", 26),
- ("陈静", 88, "可以和学生套餐组合", 24),
- ("评价 Agent", 89, "需求 92 / 竞争 80 / 可行性 88", 22),
- ],
- },
- # 早前用 skill 上传、后被判重复的一张卡:保留它,池子里才有"否决"的真实样本
- {
- "seedKey": "seed-opp-9",
- "title": "夜宵低卡拌面(与 seed-opp-1 重复)",
- "oneLiner": "陈静在 S3 独立上传的机会卡,内容与张伟的夜宵低卡高蛋白重合",
- "stage": "S3 机会识别",
- "category": "拌面",
- "priority": "B",
- "status": "rejected",
- "evidenceLevel": "low",
- "quoteCount": 0,
- "citeCount": 0,
- "upload": upload_meta("陈静", "sess-05", *MAIN, "skill", hours_ago(68)),
- "linkedProject": "",
- "linkedArtifacts": ["C6AFWrqyTQ"],
- "decision": {
- "type": "merge", "label": "否决(并入 seed-opp-1)",
- "reason": "与张伟的夜宵低卡高蛋白拌面重复,评分并入原卡,本卡留档。",
- "by": "吴敏", "at": hours_ago(64),
- },
- "scores": [],
- },
- ]
- # ---------------------------------------------------------------- 现场演示:一个卡住的人
- # 现场看板要能列出"谁卡住了":一条有时长但在 S4 停了 30 小时、没有任何成果的会话。
- STUCK_SESSION = {
- "seedKey": "seed-stuck-session",
- "className": "KfSession",
- "fields": {
- "studentName": "何雨",
- "groupId": "G3",
- "stage": "S4验证评估",
- "step": "1/3",
- "msgCount": 13,
- "lastActiveAt": {"__type": "Date", "iso": hours_ago(30)},
- },
- }
- CARD_FIELDS_SCHEMA = {
- "title": {"type": "String"},
- "oneLiner": {"type": "String"},
- "stage": {"type": "String"},
- "category": {"type": "String"},
- "priority": {"type": "String"},
- "status": {"type": "String"},
- "evidenceLevel": {"type": "String"},
- "quoteCount": {"type": "Number"},
- "citeCount": {"type": "Number"},
- "uploadedBy": {"type": "String"},
- "uploadedByKey": {"type": "String"},
- "agentName": {"type": "String"},
- "agentId": {"type": "String"},
- "uploadMethod": {"type": "String"},
- "uploadedAt": {"type": "Date"},
- "sourceSession": {"type": "String"},
- "linkedProject": {"type": "String"},
- "linkedArtifacts": {"type": "Array"},
- "decision": {"type": "Object"},
- "scores": {"type": "Array"},
- "decisionLog": {"type": "Array"},
- "seedKey": {"type": "String"},
- "searchText": {"type": "String"},
- }
- UPLOAD_FIELDS_SCHEMA = {
- "uploadedByKey": {"type": "String"},
- "agentName": {"type": "String"},
- "agentId": {"type": "String"},
- "uploadMethod": {"type": "String"},
- "uploadedAt": {"type": "Date"},
- }
- def seed_scores(card):
- out = []
- for person, score, comment, hours in card["scores"]:
- out.append({
- "user": person,
- "score": score,
- "comment": comment,
- "at": {"__type": "Date", "iso": hours_ago(hours)},
- "seed": card["seedKey"],
- })
- return out
- def search_text(d):
- return " ".join(str(d.get(k) or "") for k in ("title", "oneLiner", "category", "stage", "priority", "uploadedBy", "agentName")).lower()
- def main():
- if not MK:
- sys.exit("PARSE_MASTER_KEY 未设置")
- if "results" not in call("GET", "/classes/KfProject?limit=1"):
- sys.exit("masterKey 验证失败")
- # 1) schema
- # KfOpportunity 首次运行时按 CARD_FIELDS_SCHEMA 一次建全(Parse 拒绝重复改已有字段,
- # 所以已存在就跳过);KfArtifact / KfProject 只补上传元数据字段。
- has_opp = bool(call("GET", "/schemas/KfOpportunity").get("fields"))
- plan = [] if has_opp else [("KfOpportunity", CARD_FIELDS_SCHEMA)]
- plan += [("KfArtifact", UPLOAD_FIELDS_SCHEMA), ("KfProject", UPLOAD_FIELDS_SCHEMA)]
- for cls, fields in plan:
- if DRY:
- print("[dry] schema", cls, list(fields))
- continue
- r = call("PUT", f"/schemas/{cls}", {"className": cls, "fields": fields})
- print("schema", cls, "ok" if "fields" in r else r)
- # 2) 机会卡:按 seedKey 幂等
- existing = {c.get("seedKey"): c for c in fetch("KfOpportunity") if c.get("seedKey")}
- artifacts = {a["objectId"]: a for a in fetch("KfArtifact")}
- for card in CARDS:
- body = {
- "title": card["title"], "oneLiner": card["oneLiner"], "stage": card["stage"],
- "category": card["category"], "priority": card["priority"], "status": card["status"],
- "evidenceLevel": card["evidenceLevel"], "quoteCount": card["quoteCount"],
- "citeCount": card["citeCount"], "seedKey": card["seedKey"],
- "linkedProject": card.get("linkedProject") or "",
- "linkedArtifacts": [i for i in card["linkedArtifacts"] if i in artifacts],
- }
- body.update(card["upload"])
- body["uploadedAt"] = {"__type": "Date", "iso": card["upload"]["uploadedAt"]}
- body["searchText"] = search_text({**card, **card["upload"]})
- dec = dict(card["decision"], at={"__type": "Date", "iso": card["decision"]["at"]}) if card["decision"] else None
- if dec:
- body["decision"] = dec
- body["decisionLog"] = [dec] if dec else []
- cur = existing.get(card["seedKey"])
- if not cur:
- body["scores"] = seed_scores(card)
- if DRY:
- print("[dry] create", card["title"], [s[0] for s in card["scores"]])
- continue
- r = call("POST", "/classes/KfOpportunity", body)
- print("create", card["title"], "ok" if "objectId" in r else r)
- continue
- # 已存在:只补缺失的字段;不覆盖人工改过的 status / priority / decision
- patch = {}
- for k in ("uploadedBy", "uploadedByKey", "agentName", "agentId", "uploadMethod", "uploadedAt",
- "linkedProject", "linkedArtifacts", "evidenceLevel", "quoteCount", "citeCount", "oneLiner"):
- if not cur.get(k) and body.get(k):
- patch[k] = body[k]
- if not cur.get("decision") and dec:
- patch["decision"] = dec
- patch["decisionLog"] = [dec]
- live_scores = cur.get("scores") or []
- have = {(s.get("user"), s.get("seed")) for s in live_scores}
- if RESET_SCORES:
- live_scores = [s for s in live_scores if not s.get("seed")]
- missing = [s for s in seed_scores(card) if (s["user"], s["seed"]) not in have]
- if len(live_scores) != len(cur.get("scores") or []) or missing:
- patch["scores"] = live_scores + missing
- if patch:
- if DRY:
- print("[dry] patch", card["title"], list(patch))
- continue
- r = call("PUT", f"/classes/KfOpportunity/{cur['objectId']}", patch)
- print("patch", card["title"], list(patch), "ok" if "updatedAt" in r else r)
- else:
- print("keep ", card["title"], "(已存在且完整)")
- # 3) 老成果 / 项目补齐上传元数据(只补缺的)
- owner_method = {}
- for card in CARDS:
- owner_method[card["upload"]["uploadedBy"]] = (card["upload"], card["upload"]["uploadedByKey"])
- for cls in ("KfArtifact", "KfProject"):
- rows = fetch(cls)
- for row in rows:
- patch = {}
- if not row.get("uploadedByKey"):
- patch["uploadedByKey"] = row.get("ownerSession") or row.get("ownerName") or ""
- if not row.get("agentName"):
- patch["agentName"] = MAIN[0]
- patch["agentId"] = MAIN[1]
- if not row.get("uploadMethod"):
- patch["uploadMethod"] = "skill" # 现有 26 条成果全部是 jarvis skill 写入
- if not row.get("uploadedAt") and row.get("createdAt"):
- patch["uploadedAt"] = {"__type": "Date", "iso": row.get("createdAt")}
- if not patch:
- continue
- if DRY:
- continue
- r = call("PUT", f"/classes/{cls}/{row['objectId']}", patch)
- if "updatedAt" not in r:
- print("ERR upload-meta", cls, row["objectId"], r)
- print(f"upload-meta {cls}: {len(rows)} 行已检查")
- # 4) 现场演示:卡住的人
- s = STUCK_SESSION
- have = [x for x in fetch(s["className"]) if x.get("seedKey") == s["seedKey"]]
- if have:
- print("keep stuck-session", have[0]["objectId"])
- elif DRY:
- print("[dry] create stuck-session", s["fields"]["studentName"])
- else:
- r = call("POST", f"/classes/{s['className']}", {**s["fields"], "seedKey": s["seedKey"]})
- ok = "objectId" in r
- print("create stuck-session", "ok" if ok else r)
- if not ok:
- # KfSession 没有 seedKey 字段时,退回用姓名查找
- found = [x for x in fetch("KfSession") if x.get("studentName") == s["fields"]["studentName"]]
- print(" fallback find:", len(found))
- if __name__ == "__main__":
- main()
|