gen_tasks_from_protocol.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. #!/usr/bin/env python3
  2. """由干预方案库(protocols/*.json)生成任务种子 SQL,替代硬编码的 gen_seed.py。
  3. 用法:
  4. python3 gen_tasks_from_protocol.py [知识库目录] [选项]
  5. python3 gen_tasks_from_protocol.py --out /tmp/protocol_seed.sql
  6. python3 gen_tasks_from_protocol.py --summary
  7. 默认读取 <知识库目录>/protocols/*.json(默认 docs/参考资料/知识库),
  8. 输出幂等 SQL 到 stdout(或 --out 指定文件)。SQL 结构与历史 gen_seed.py 一致:
  9. - 时段补剂 -> 每日重复模板(title=【周期名】{time} {label}:{dose},desc=notes)
  10. - 每日监测 -> 每日重复模板(title=name,desc=description,跨全周期)
  11. - 每周监测 -> 每周重复模板(按 weekday,跨全周期)
  12. - 运动任务 -> 每日重复模板(title=title,desc=description,跨全周期)
  13. - 复查节点 -> 一次性任务(title=name,desc=note,deadline=due 09:00)
  14. - 提醒配置 -> 每成员一条 task_reminder_config(取 protocol.notifications)
  15. 幂等:INSERT ... SELECT ... WHERE NOT EXISTS(title+family_id+executor_id+is_template),
  16. 重复执行不产生重复行。生产执行需另经 go/no-go(本脚本仅生成 SQL,不连库)。
  17. """
  18. import argparse
  19. import datetime as dt
  20. import json
  21. import re
  22. import sys
  23. from pathlib import Path
  24. DEFAULT_KB = "docs/参考资料/知识库"
  25. WEEKDAY = {"周一": 0, "周二": 1, "周三": 2, "周四": 3, "周五": 4, "周六": 5, "周日": 6, "周天": 6}
  26. COLS = ("family_id,creator_id,child_id,executor_type,executor_id,family_member_id,title,description,"
  27. "points,category,need_review,review_by_category,status,is_template,task_type,repeat_type,frequency,"
  28. "earliest_start,deadline,source_type,is_daily_progress,member_only,require_input,start_required,"
  29. "min_duration_seconds,points_expire_days,duration")
  30. def esc(s):
  31. return s.replace("'", "''") if s else s
  32. def ts(d, t):
  33. return f"{d} {t}:00"
  34. def _tpl(base, title, desc, slot, start, end, freq, wd=None, category="health", source="health_plan"):
  35. if freq == "weekly":
  36. d = dt.date.fromisoformat(end)
  37. while d.weekday() != wd:
  38. d -= dt.timedelta(days=1)
  39. deadline = ts(d.isoformat(), slot)
  40. else:
  41. deadline = ts(end, slot)
  42. return dict(kind="tpl", **base, title=title, desc=desc, deadline=deadline,
  43. earliest=ts(start, "00:00"), freq=freq, task_type="recurring",
  44. category=category, source=source)
  45. def _once(base, title, desc, due, slot="09:00", category="health", source="health_plan"):
  46. return dict(kind="once", **base, title=title, desc=desc, deadline=ts(due, slot),
  47. earliest=None, freq="none", task_type="onetime", category=category, source=source)
  48. def build_rows(proto):
  49. cycles = {c["id"]: c for c in proto["cycles"]}
  50. gen = proto.get("generation", {})
  51. category = gen.get("category", "health")
  52. source = gen.get("source_type", "health_plan")
  53. period_start = min(c["start"] for c in proto["cycles"])
  54. period_end = max(c["end"] for c in proto["cycles"])
  55. rows = []
  56. for m in proto["members"]:
  57. base = dict(user=m["user_id"], family=m["family_id"], fm=m["family_member_id"])
  58. prefix_cycle = len(m["phases"]) > 1
  59. for phase in m["phases"]:
  60. cyc = cycles[phase["cycle_id"]]
  61. for slot in phase["time_slots"]:
  62. title = f"{slot['time']} {slot['label']}:{slot['dose']}"
  63. if prefix_cycle:
  64. title = f"【{cyc['name']}】" + title
  65. rows.append(_tpl(base, title, slot["notes"], slot["time"],
  66. cyc["start"], cyc["end"], "daily", category=category, source=source))
  67. for d in m.get("daily_monitoring", []):
  68. rows.append(_tpl(base, d["name"], d.get("description", ""), d["time"],
  69. period_start, period_end, "daily", category=category, source=source))
  70. for w in m.get("weekly_monitoring", []):
  71. rows.append(_tpl(base, w["name"], w.get("description", ""), w.get("time", "09:00"),
  72. period_start, period_end, "weekly", wd=WEEKDAY[w["weekday"]],
  73. category=category, source=source))
  74. for e in m.get("exercise_tasks", []):
  75. rows.append(_tpl(base, e["title"], e.get("description", ""), e["time"],
  76. period_start, period_end, "daily", category=category, source=source))
  77. for f in m.get("followups", []):
  78. rows.append(_once(base, f["name"], f.get("note", ""), f["due"],
  79. category=category, source=source))
  80. return rows
  81. def render_sql(protos):
  82. out = ["-- CFC 干预方案库 -> 任务种子(幂等;由 gen_tasks_from_protocol.py 生成,勿手工编辑)"]
  83. total_tpl = total_once = 0
  84. for proto in protos:
  85. rows = build_rows(proto)
  86. out.append(f"-- 方案:{proto['name']}({proto['id']})"
  87. f" 周期 {min(c['start'] for c in proto['cycles'])}~{max(c['end'] for c in proto['cycles'])}")
  88. out.append("")
  89. for r in rows:
  90. is_tpl = 1 if r["kind"] == "tpl" else 0
  91. if is_tpl:
  92. total_tpl += 1
  93. else:
  94. total_once += 1
  95. earliest_sql = f"'{r['earliest']}'" if r["earliest"] else "NULL"
  96. vals = (f"{r['family']},{r['user']},{r['fm']},'parent',{r['user']},{r['fm']},"
  97. f"'{esc(r['title'])}','{esc(r['desc'])}',1,'{r['category']}',0,0,'pending',{is_tpl},"
  98. f"'{r['task_type']}','none','{r['freq']}',{earliest_sql},'{r['deadline']}','{r['source']}',"
  99. f"0,0,0,0,0,0,0")
  100. dup = (f"SELECT 1 FROM tasks t WHERE t.title='{esc(r['title'])}' AND t.family_id={r['family']} "
  101. f"AND t.executor_id={r['user']} AND t.is_template={is_tpl}")
  102. out.append(f"INSERT INTO tasks ({COLS})\nSELECT {vals} FROM DUAL\nWHERE NOT EXISTS ({dup});")
  103. out.append("")
  104. notif = proto.get("notifications")
  105. if notif:
  106. for m in proto["members"]:
  107. out.append(f"-- {m['name']} 提醒配置({notif['channels']},{notif['timing_mode']} "
  108. f"{notif['offset_minutes']}分钟)")
  109. out.append(
  110. "INSERT INTO task_reminder_config (user_id,channels,timing_mode,offset_minutes,enabled)\n"
  111. f"SELECT {m['user_id']},'{notif['channels']}','{notif['timing_mode']}',"
  112. f"{notif['offset_minutes']},{notif['enabled']} FROM DUAL\n"
  113. f"WHERE NOT EXISTS (SELECT 1 FROM task_reminder_config WHERE user_id={m['user_id']});")
  114. out.append("")
  115. out.append(f"-- 统计:模板 {total_tpl} 条,一次性 {total_once} 条,合计 {total_tpl + total_once} 条")
  116. return "\n".join(out), total_tpl, total_once
  117. def main():
  118. ap = argparse.ArgumentParser(description="由干预方案库生成幂等任务种子 SQL")
  119. ap.add_argument("kb_dir", nargs="?", default=DEFAULT_KB, help="知识库目录(默认 %(default)s)")
  120. ap.add_argument("--out", help="输出 SQL 文件路径(默认 stdout)")
  121. ap.add_argument("--summary", action="store_true", help="仅打印统计摘要")
  122. args = ap.parse_args()
  123. proto_dir = Path(args.kb_dir) / "protocols"
  124. files = sorted(proto_dir.glob("*.json"))
  125. if not files:
  126. print(f"错误:{proto_dir} 下未找到协议 JSON", file=sys.stderr)
  127. sys.exit(2)
  128. protos = [json.loads(p.read_text(encoding="utf-8")) for p in files]
  129. for p in protos:
  130. if not re.match(r"^protocol-[a-z0-9-]+$", p.get("id", "")):
  131. print(f"错误:协议 id 非法: {p.get('id')!r}", file=sys.stderr)
  132. sys.exit(2)
  133. sql, tpl, once = render_sql(protos)
  134. if args.summary:
  135. print(f"协议 {len(protos)} 个 | 模板 {tpl} 条 | 一次性 {once} 条 | 合计 {tpl + once} 条")
  136. return
  137. if args.out:
  138. Path(args.out).write_text(sql + "\n", encoding="utf-8")
  139. print(f"已写入 {args.out}(模板 {tpl},一次性 {once},合计 {tpl + once})")
  140. else:
  141. print(sql)
  142. if __name__ == "__main__":
  143. main()