seed_opportunities.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. """seed_opportunities.py —— 造机会卡示例数据(KfOpportunity)+ 上传元数据 + 一条"卡住"的现场数据。
  2. 为什么单独一个类:机会卡有独立生命周期(评分、状态、决策记录、优先级),跟"成果"不是一回事。
  3. 理由见 README「为什么新建 KfOpportunity」。
  4. 可重复执行(幂等):
  5. - 每张卡带 seedKey;已存在同 seedKey 的卡:只补缺失的上传元数据 / 关联成果 / 历史评分,不覆盖人工改过的 status/priority/decision;
  6. - 评分历史 [seed:xxx] 按 seedKey 去重,不会越跑越多;
  7. - 现场演示用的卡住数据同样按 seedKey 幂等。
  8. PARSE_MASTER_KEY=... python3 tools/seed_opportunities.py # 写入
  9. PARSE_MASTER_KEY=... python3 tools/seed_opportunities.py --dry-run # 只看要写什么
  10. PARSE_MASTER_KEY=... python3 tools/seed_opportunities.py --reset-scores # 把示例评分恢复到初始值
  11. 凭据只从环境变量读,不落盘。
  12. """
  13. import json
  14. import os
  15. import sys
  16. import urllib.error
  17. import urllib.request
  18. from datetime import datetime, timedelta, timezone
  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. RESET_SCORES = "--reset-scores" in sys.argv
  24. H = {"X-Parse-Application-Id": APP_ID, "Content-Type": "application/json"}
  25. if MK:
  26. H["X-Parse-Master-Key"] = MK
  27. NOW = datetime.now(timezone.utc)
  28. def iso(dt):
  29. return dt.strftime("%Y-%m-%dT%H:%M:%S.") + "%03dZ" % (dt.microsecond // 1000)
  30. def hours_ago(h):
  31. return iso(NOW - timedelta(hours=h))
  32. def call(method, path, body=None):
  33. data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
  34. req = urllib.request.Request(BASE + path, data=data, headers=H, method=method)
  35. try:
  36. with urllib.request.urlopen(req, timeout=30) as r:
  37. return json.loads(r.read().decode() or "{}")
  38. except urllib.error.HTTPError as e:
  39. return {"_error": e.code, "_body": e.read().decode()[:400]}
  40. def fetch(cls):
  41. return call("GET", f"/classes/{cls}?limit=1000&order=createdAt").get("results", [])
  42. # ---------------------------------------------------------------- 上传元数据
  43. # 所有"被上传的东西"都带这五个字段;老数据补齐 uploadedByKey 的值由这里统一推导。
  44. UPLOAD_METHODS = {"session": "会话上传", "skill": "Skill 上传", "manual": "手工录入"}
  45. def upload_meta(person, key, agent_name, agent_id, method, at, session=None):
  46. m = {
  47. "uploadedBy": person,
  48. "uploadedByKey": key,
  49. "agentName": agent_name,
  50. "agentId": agent_id,
  51. "uploadMethod": method,
  52. "uploadedAt": at,
  53. }
  54. if session:
  55. m["sourceSession"] = session
  56. return m
  57. MAIN = ("康师傅产品创新助手", "agent-main-01") # 主分身:学员在会话里把想法交给它
  58. RESEARCH = ("品类调研 Agent", "agent-research-02") # 调研 Agent:自动抓 VOC / 竞品
  59. CHECK = ("机会评估 Agent", "agent-check-03") # 评估 Agent:按评分模型打分
  60. DESIGN = ("概念设计 Agent", "agent-design-04") # 设计 Agent:把机会变成方案
  61. # ---------------------------------------------------------------- 机会卡
  62. # 每张卡都挂到库里真实存在的成果(KfArtifact / KfProject),保证"点开看报告/证据"有东西。
  63. # scores:[评分人, 分数, 评语, 几小时前, seedKey]
  64. CARDS = [
  65. {
  66. "seedKey": "seed-opp-1",
  67. "title": "夜宵场景低卡高蛋白拌面",
  68. "oneLiner": "加班到十点想吃饱又怕胖:低卡高蛋白 + 1 分钟即食,5 个夜宵场景里 3 个需求未被满足",
  69. "stage": "S3 机会识别",
  70. "category": "拌面",
  71. "priority": "S",
  72. "status": "candidate",
  73. "evidenceLevel": "high",
  74. "quoteCount": 12,
  75. "citeCount": 8,
  76. "upload": upload_meta("张伟", "sess-01", *MAIN, "session", hours_ago(6), session="sess-01"),
  77. "linkedProject": "QFvrQsUPyD",
  78. "linkedArtifacts": ["KHEScCO5dT", "5icUD1vUVN", "BjBza4kr2U"],
  79. "decision": {
  80. "type": "concept", "label": "进入概念验证",
  81. "reason": "S3 阶段 5 位成员独立提到夜宵场景的「饱腹 + 低卡」矛盾;已有 12 条带原文的 VOC 支撑。建议 S5 做一次概念测试。",
  82. "by": "张伟", "at": hours_ago(4),
  83. },
  84. "scores": [
  85. ("李娜", 86, "场景真实,VOC 密度高,可以做", 9),
  86. ("吴敏", 90, "和现有夜宵 SKU 不冲突,值得立项", 7),
  87. ("周涛", 84, "高蛋白卖点需要工艺支持,先验证", 5),
  88. ("评价 Agent", 88, "人群规模 × 需求强度 × 可行性 综合 88", 3),
  89. ],
  90. },
  91. {
  92. "seedKey": "seed-opp-2",
  93. "title": "办公室 3 分钟速食拌面",
  94. "oneLiner": "午休只有 10 分钟、不想吃汤汤水水:免煮、不冒汤、可带去办公室的拌面",
  95. "stage": "S4 验证评估",
  96. "category": "拌面",
  97. "priority": "A",
  98. "status": "approved",
  99. "evidenceLevel": "high",
  100. "quoteCount": 9,
  101. "citeCount": 5,
  102. "upload": upload_meta("李娜", "sess-02", *MAIN, "session", hours_ago(30), session="sess-02"),
  103. "linkedProject": "PzNTNt7ttt",
  104. "linkedArtifacts": ["BjBza4kr2U", "yGwGJ9aPu6"],
  105. "decision": {
  106. "type": "approve", "label": "立项",
  107. "reason": "S4 验证完成:三家竞品均无免汤方案,目标人群 2.7 亿中约 18% 处于该场景;评分模型 82 分,过线。",
  108. "by": "刘洋", "at": hours_ago(20),
  109. },
  110. "scores": [
  111. ("刘洋", 84, "验证数据齐,建议立项", 26),
  112. ("王芳", 80, "价格带要卡住 8 元", 22),
  113. ("评价 Agent", 82, "需求 85 / 竞争 78 / 可行性 83", 20),
  114. ],
  115. },
  116. {
  117. "seedKey": "seed-opp-3",
  118. "title": "地域风味麻酱拌面",
  119. "oneLiner": "现有拌面酱包集中在麻酱 / 香辣两类;西北油泼、西南麻辣是搜索热但 SKU 空档",
  120. "stage": "S4 验证评估",
  121. "category": "拌面",
  122. "priority": "A",
  123. "status": "candidate",
  124. "evidenceLevel": "medium",
  125. "quoteCount": 6,
  126. "citeCount": 3,
  127. "upload": upload_meta("刘洋", "sess-04", *RESEARCH, "skill", hours_ago(52)),
  128. "linkedProject": "",
  129. "linkedArtifacts": ["QVrfdasAkg", "C6AFWrqyTQ"],
  130. "decision": {
  131. "type": "hold", "label": "暂缓(补原文)",
  132. "reason": "方向认可,但支撑它的 6 条记录里只有 3 条带原文,销量数据待调研 Agent 回传。补齐前不排产。",
  133. "by": "赵磊", "at": hours_ago(40),
  134. },
  135. "scores": [
  136. ("赵磊", 72, "方向对,证据不够", 45),
  137. ("陈静", 78, "和夜宵低卡可以合并做双口味", 38),
  138. ("评价 Agent", 75, "证据强度拖后腿", 36),
  139. ],
  140. },
  141. {
  142. "seedKey": "seed-opp-4",
  143. "title": "露营便携免煮拌面",
  144. "oneLiner": "露营带的都是泡面,想吃拌面又得煮:冷水泡 8 分钟、自带拌酱的免煮方案",
  145. "stage": "S5 场景与卖点设计",
  146. "category": "杯面",
  147. "priority": "S",
  148. "status": "candidate",
  149. "evidenceLevel": "medium",
  150. "quoteCount": 7,
  151. "citeCount": 4,
  152. "upload": upload_meta("陈静", "sess-05", *DESIGN, "skill", hours_ago(2)),
  153. "linkedProject": "",
  154. "linkedArtifacts": ["mdJAkc5u6X", "w5DDt2Qek4"],
  155. "decision": {
  156. "type": "concept", "label": "进入概念验证",
  157. "reason": "露营搜索量 2026 春夏环比 +41%;已有方案具备卖点抓手(「不烧水也能拌」)。等 S4 评分补齐。",
  158. "by": "陈静", "at": hours_ago(1.5),
  159. },
  160. "scores": [
  161. ("孙悦", 79, "场景有意思,量可能不大", 8),
  162. ("郑凯", 81, "卖点很清晰,可以做包装测试", 2),
  163. ],
  164. },
  165. {
  166. "seedKey": "seed-opp-5",
  167. "title": "健身人群高蛋白面",
  168. "oneLiner": "健身人群把速食面当「欺骗餐」,要的是每份 20g 以上蛋白、低脂、能算进宏量",
  169. "stage": "S2 用户需求与痛点",
  170. "category": "汤面",
  171. "priority": "B",
  172. "status": "rejected",
  173. "evidenceLevel": "low",
  174. "quoteCount": 2,
  175. "citeCount": 1,
  176. "upload": upload_meta("孙悦", "sess-07", *MAIN, "session", hours_ago(76), session="sess-07"),
  177. "linkedProject": "",
  178. "linkedArtifacts": ["0TufH1peEG", "rSqN03GPux", "WrWlJ1lSHC"],
  179. "decision": {
  180. "type": "reject", "label": "否决",
  181. "reason": "三条支撑记录里只有 2 条原文,且蛋白成本会把单价推到 15 元以上,和现有价格带冲突。留档,不做。",
  182. "by": "吴敏", "at": hours_ago(60),
  183. },
  184. "scores": [
  185. ("吴敏", 58, "成本算不过来", 70),
  186. ("周涛", 62, "需求真实但规模小", 66),
  187. ("评价 Agent", 55, "人群规模 ★★,可行性 ★★", 62),
  188. ],
  189. },
  190. {
  191. "seedKey": "seed-opp-6",
  192. "title": "银发低钠汤面",
  193. "oneLiner": "50 岁以上人群控盐诉求明确,现有方便面钠含量普遍 2000mg 以上,无低钠 SKU",
  194. "stage": "S1 品类市场分析",
  195. "category": "汤面",
  196. "priority": "B",
  197. "status": "candidate",
  198. "evidenceLevel": "medium",
  199. "quoteCount": 4,
  200. "citeCount": 2,
  201. "upload": upload_meta("赵磊", "sess-06", *MAIN, "manual", hours_ago(50)),
  202. "linkedProject": "",
  203. "linkedArtifacts": ["Lor4psRme0", "rvyuQ3985Q"],
  204. "decision": None,
  205. "scores": [
  206. ("赵磊", 70, "合规是卖点,工艺要改", 48),
  207. ],
  208. },
  209. {
  210. "seedKey": "seed-opp-7",
  211. "title": "夏日清爽凉拌面",
  212. "oneLiner": "夏季 6–8 月拌面搜索 +35%,但现有产品多为「热拌」,冷拌 / 冰镇吃法没有对应 SKU",
  213. "stage": "S3 机会识别",
  214. "category": "拌面",
  215. "priority": "A",
  216. "status": "candidate",
  217. "evidenceLevel": "medium",
  218. "quoteCount": 5,
  219. "citeCount": 3,
  220. "upload": upload_meta("周涛", "sess-08", *RESEARCH, "skill", hours_ago(0.4)),
  221. "linkedProject": "",
  222. "linkedArtifacts": ["9Mj4LV3a3P", "czLP44qeOl", "W42NYTiJFm"],
  223. "decision": {
  224. "type": "hold", "label": "指派评估",
  225. "reason": "季节性明确,需要 S4 用评分模型验证「冷藏口感」这一工艺风险。",
  226. "by": "吴敏", "at": hours_ago(0.2),
  227. },
  228. "scores": [
  229. ("周涛", 76, "季节性太强,看全年怎么讲", 0.3),
  230. ],
  231. },
  232. {
  233. "seedKey": "seed-opp-8",
  234. "title": "学生党实惠大份拌面",
  235. "oneLiner": "宿舍场景吃一碗不饱:同价位加量 30%,以「三包面饼」作为卖点",
  236. "stage": "S3 机会识别",
  237. "category": "炒面",
  238. "priority": "S",
  239. "status": "candidate",
  240. "evidenceLevel": "medium",
  241. "quoteCount": 5,
  242. "citeCount": 2,
  243. "upload": upload_meta("王芳", "sess-03", *MAIN, "session", hours_ago(28), session="sess-03"),
  244. "linkedProject": "gC9Ra0bnXf",
  245. "linkedArtifacts": ["OG7w0dxbja", "HmZG9wFEq7"],
  246. "decision": {
  247. "type": "concept", "label": "进入概念验证",
  248. "reason": "「吃不饱」在差评里频次高于「难吃」;加量 + 不涨价可行性待工艺评估。",
  249. "by": "王芳", "at": hours_ago(24),
  250. },
  251. "scores": [
  252. ("王芳", 91, "最接近真实痛点的一张卡", 26),
  253. ("陈静", 88, "可以和学生套餐组合", 24),
  254. ("评价 Agent", 89, "需求 92 / 竞争 80 / 可行性 88", 22),
  255. ],
  256. },
  257. # 早前用 skill 上传、后被判重复的一张卡:保留它,池子里才有"否决"的真实样本
  258. {
  259. "seedKey": "seed-opp-9",
  260. "title": "夜宵低卡拌面(与 seed-opp-1 重复)",
  261. "oneLiner": "陈静在 S3 独立上传的机会卡,内容与张伟的夜宵低卡高蛋白重合",
  262. "stage": "S3 机会识别",
  263. "category": "拌面",
  264. "priority": "B",
  265. "status": "rejected",
  266. "evidenceLevel": "low",
  267. "quoteCount": 0,
  268. "citeCount": 0,
  269. "upload": upload_meta("陈静", "sess-05", *MAIN, "skill", hours_ago(68)),
  270. "linkedProject": "",
  271. "linkedArtifacts": ["C6AFWrqyTQ"],
  272. "decision": {
  273. "type": "merge", "label": "否决(并入 seed-opp-1)",
  274. "reason": "与张伟的夜宵低卡高蛋白拌面重复,评分并入原卡,本卡留档。",
  275. "by": "吴敏", "at": hours_ago(64),
  276. },
  277. "scores": [],
  278. },
  279. ]
  280. # ---------------------------------------------------------------- 现场演示:一个卡住的人
  281. # 现场看板要能列出"谁卡住了":一条有时长但在 S4 停了 30 小时、没有任何成果的会话。
  282. STUCK_SESSION = {
  283. "seedKey": "seed-stuck-session",
  284. "className": "KfSession",
  285. "fields": {
  286. "studentName": "何雨",
  287. "groupId": "G3",
  288. "stage": "S4验证评估",
  289. "step": "1/3",
  290. "msgCount": 13,
  291. "lastActiveAt": {"__type": "Date", "iso": hours_ago(30)},
  292. },
  293. }
  294. CARD_FIELDS_SCHEMA = {
  295. "title": {"type": "String"},
  296. "oneLiner": {"type": "String"},
  297. "stage": {"type": "String"},
  298. "category": {"type": "String"},
  299. "priority": {"type": "String"},
  300. "status": {"type": "String"},
  301. "evidenceLevel": {"type": "String"},
  302. "quoteCount": {"type": "Number"},
  303. "citeCount": {"type": "Number"},
  304. "uploadedBy": {"type": "String"},
  305. "uploadedByKey": {"type": "String"},
  306. "agentName": {"type": "String"},
  307. "agentId": {"type": "String"},
  308. "uploadMethod": {"type": "String"},
  309. "uploadedAt": {"type": "Date"},
  310. "sourceSession": {"type": "String"},
  311. "linkedProject": {"type": "String"},
  312. "linkedArtifacts": {"type": "Array"},
  313. "decision": {"type": "Object"},
  314. "scores": {"type": "Array"},
  315. "decisionLog": {"type": "Array"},
  316. "seedKey": {"type": "String"},
  317. "searchText": {"type": "String"},
  318. }
  319. UPLOAD_FIELDS_SCHEMA = {
  320. "uploadedByKey": {"type": "String"},
  321. "agentName": {"type": "String"},
  322. "agentId": {"type": "String"},
  323. "uploadMethod": {"type": "String"},
  324. "uploadedAt": {"type": "Date"},
  325. }
  326. def seed_scores(card):
  327. out = []
  328. for person, score, comment, hours in card["scores"]:
  329. out.append({
  330. "user": person,
  331. "score": score,
  332. "comment": comment,
  333. "at": {"__type": "Date", "iso": hours_ago(hours)},
  334. "seed": card["seedKey"],
  335. })
  336. return out
  337. def search_text(d):
  338. return " ".join(str(d.get(k) or "") for k in ("title", "oneLiner", "category", "stage", "priority", "uploadedBy", "agentName")).lower()
  339. def main():
  340. if not MK:
  341. sys.exit("PARSE_MASTER_KEY 未设置")
  342. if "results" not in call("GET", "/classes/KfProject?limit=1"):
  343. sys.exit("masterKey 验证失败")
  344. # 1) schema
  345. # KfOpportunity 首次运行时按 CARD_FIELDS_SCHEMA 一次建全(Parse 拒绝重复改已有字段,
  346. # 所以已存在就跳过);KfArtifact / KfProject 只补上传元数据字段。
  347. has_opp = bool(call("GET", "/schemas/KfOpportunity").get("fields"))
  348. plan = [] if has_opp else [("KfOpportunity", CARD_FIELDS_SCHEMA)]
  349. plan += [("KfArtifact", UPLOAD_FIELDS_SCHEMA), ("KfProject", UPLOAD_FIELDS_SCHEMA)]
  350. for cls, fields in plan:
  351. if DRY:
  352. print("[dry] schema", cls, list(fields))
  353. continue
  354. r = call("PUT", f"/schemas/{cls}", {"className": cls, "fields": fields})
  355. print("schema", cls, "ok" if "fields" in r else r)
  356. # 2) 机会卡:按 seedKey 幂等
  357. existing = {c.get("seedKey"): c for c in fetch("KfOpportunity") if c.get("seedKey")}
  358. artifacts = {a["objectId"]: a for a in fetch("KfArtifact")}
  359. for card in CARDS:
  360. body = {
  361. "title": card["title"], "oneLiner": card["oneLiner"], "stage": card["stage"],
  362. "category": card["category"], "priority": card["priority"], "status": card["status"],
  363. "evidenceLevel": card["evidenceLevel"], "quoteCount": card["quoteCount"],
  364. "citeCount": card["citeCount"], "seedKey": card["seedKey"],
  365. "linkedProject": card.get("linkedProject") or "",
  366. "linkedArtifacts": [i for i in card["linkedArtifacts"] if i in artifacts],
  367. }
  368. body.update(card["upload"])
  369. body["uploadedAt"] = {"__type": "Date", "iso": card["upload"]["uploadedAt"]}
  370. body["searchText"] = search_text({**card, **card["upload"]})
  371. dec = dict(card["decision"], at={"__type": "Date", "iso": card["decision"]["at"]}) if card["decision"] else None
  372. if dec:
  373. body["decision"] = dec
  374. body["decisionLog"] = [dec] if dec else []
  375. cur = existing.get(card["seedKey"])
  376. if not cur:
  377. body["scores"] = seed_scores(card)
  378. if DRY:
  379. print("[dry] create", card["title"], [s[0] for s in card["scores"]])
  380. continue
  381. r = call("POST", "/classes/KfOpportunity", body)
  382. print("create", card["title"], "ok" if "objectId" in r else r)
  383. continue
  384. # 已存在:只补缺失的字段;不覆盖人工改过的 status / priority / decision
  385. patch = {}
  386. for k in ("uploadedBy", "uploadedByKey", "agentName", "agentId", "uploadMethod", "uploadedAt",
  387. "linkedProject", "linkedArtifacts", "evidenceLevel", "quoteCount", "citeCount", "oneLiner"):
  388. if not cur.get(k) and body.get(k):
  389. patch[k] = body[k]
  390. if not cur.get("decision") and dec:
  391. patch["decision"] = dec
  392. patch["decisionLog"] = [dec]
  393. live_scores = cur.get("scores") or []
  394. have = {(s.get("user"), s.get("seed")) for s in live_scores}
  395. if RESET_SCORES:
  396. live_scores = [s for s in live_scores if not s.get("seed")]
  397. missing = [s for s in seed_scores(card) if (s["user"], s["seed"]) not in have]
  398. if len(live_scores) != len(cur.get("scores") or []) or missing:
  399. patch["scores"] = live_scores + missing
  400. if patch:
  401. if DRY:
  402. print("[dry] patch", card["title"], list(patch))
  403. continue
  404. r = call("PUT", f"/classes/KfOpportunity/{cur['objectId']}", patch)
  405. print("patch", card["title"], list(patch), "ok" if "updatedAt" in r else r)
  406. else:
  407. print("keep ", card["title"], "(已存在且完整)")
  408. # 3) 老成果 / 项目补齐上传元数据(只补缺的)
  409. owner_method = {}
  410. for card in CARDS:
  411. owner_method[card["upload"]["uploadedBy"]] = (card["upload"], card["upload"]["uploadedByKey"])
  412. for cls in ("KfArtifact", "KfProject"):
  413. rows = fetch(cls)
  414. for row in rows:
  415. patch = {}
  416. if not row.get("uploadedByKey"):
  417. patch["uploadedByKey"] = row.get("ownerSession") or row.get("ownerName") or ""
  418. if not row.get("agentName"):
  419. patch["agentName"] = MAIN[0]
  420. patch["agentId"] = MAIN[1]
  421. if not row.get("uploadMethod"):
  422. patch["uploadMethod"] = "skill" # 现有 26 条成果全部是 jarvis skill 写入
  423. if not row.get("uploadedAt") and row.get("createdAt"):
  424. patch["uploadedAt"] = {"__type": "Date", "iso": row.get("createdAt")}
  425. if not patch:
  426. continue
  427. if DRY:
  428. continue
  429. r = call("PUT", f"/classes/{cls}/{row['objectId']}", patch)
  430. if "updatedAt" not in r:
  431. print("ERR upload-meta", cls, row["objectId"], r)
  432. print(f"upload-meta {cls}: {len(rows)} 行已检查")
  433. # 4) 现场演示:卡住的人
  434. s = STUCK_SESSION
  435. have = [x for x in fetch(s["className"]) if x.get("seedKey") == s["seedKey"]]
  436. if have:
  437. print("keep stuck-session", have[0]["objectId"])
  438. elif DRY:
  439. print("[dry] create stuck-session", s["fields"]["studentName"])
  440. else:
  441. r = call("POST", f"/classes/{s['className']}", {**s["fields"], "seedKey": s["seedKey"]})
  442. ok = "objectId" in r
  443. print("create stuck-session", "ok" if ok else r)
  444. if not ok:
  445. # KfSession 没有 seedKey 字段时,退回用姓名查找
  446. found = [x for x in fetch("KfSession") if x.get("studentName") == s["fields"]["studentName"]]
  447. print(" fallback find:", len(found))
  448. if __name__ == "__main__":
  449. main()