From 06c053d7d7120cc8cde111f4c76217cef62433d2 Mon Sep 17 00:00:00 2001 From: taojianmin Date: Mon, 14 Sep 2026 14:17:43 +0800 Subject: [PATCH] =?UTF-8?q?v1.1.0:=20=E5=AE=9E=E7=8E=B0=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E9=80=BB=E8=BE=91=EF=BC=88=E6=89=AB=E6=8F=8F?= =?UTF-8?q?/=E4=BB=BB=E5=8A=A1=E4=B9=A6/=E6=B4=BE=E5=8F=91/=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=9C=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增(scripts/ 分层完整实现): - scripts/util/{constants,date_util,runtime_paths,logging_setup}.py - scripts/db/{connection,seed_data,repository}.py(SQLite + 5 张表 + 50 份样例合同) - scripts/service/{scanner,task_generator,dispatcher,html_renderer}.py - scripts/cli/app.py(argparse 子命令) - scripts/templates/task.html.j2(Jinja2 渲染,无 jinja2 时降级到 str.replace) - examples/{contracts_sample,performance_sample}.csv(本地样例数据) 修改: - scripts/main.py:argparse + jiangchang_skill_core 降级 - SKILL.md:version 1.0.1 → 1.1.0 - CHANGELOG.md:加 v1.1.0 条目 - .env.example:加 DB_PATH / HTML_OUTPUT_DIR / JIANGCHANG_DATA_DIR 本地验证(9 个 CLI 命令全部 exit code = 0): - health / version / config-path / init-db:基础 4 命令 - scan-expiring-contracts --thresholds 90,60,30:42 份可续聘 → 红 10 / 橙 10 / 黄 10,生成 30 任务 - render-html:写 output/.html(4.9 KB) - generate-renewal-task:单份合同任务书生成 - mark-contract-renewed:状态机更新 + 关闭 open alerts - dispatch-task-notice:按单位/月份派发 payload(dry-run 模式) 业务规则(AI 续聘建议本地版,规则顺序判断): 1. 师德 < 60 → non_renew 2. 近 3 年考核有不合格(< 60)→ observe 3. 全部 >= 80 且逐年上升 → renew 4. 其他 → observe Co-Authored-By: Claude Code --- .env.example | 11 +- .gitignore | 10 +- CHANGELOG.md | 16 ++ SKILL.md | 2 +- examples/contracts_sample.csv | 51 ++++++ examples/performance_sample.csv | 151 ++++++++++++++++ scripts/cli/app.py | 274 ++++++++++++++++++++++++++++++ scripts/db/connection.py | 134 +++++++++++++++ scripts/db/repository.py | 195 +++++++++++++++++++++ scripts/db/seed_data.py | 90 ++++++++++ scripts/main.py | 112 ++++-------- scripts/service/dispatcher.py | 123 ++++++++++++++ scripts/service/html_renderer.py | 114 +++++++++++++ scripts/service/scanner.py | 88 ++++++++++ scripts/service/task_generator.py | 145 ++++++++++++++++ scripts/templates/task.html.j2 | 118 +++++++++++++ scripts/util/constants.py | 45 +++++ scripts/util/date_util.py | 64 +++++++ scripts/util/logging_setup.py | 53 ++++++ scripts/util/runtime_paths.py | 82 +++++++++ 20 files changed, 1796 insertions(+), 82 deletions(-) create mode 100644 examples/contracts_sample.csv create mode 100644 examples/performance_sample.csv create mode 100644 scripts/cli/app.py create mode 100644 scripts/db/connection.py create mode 100644 scripts/db/repository.py create mode 100644 scripts/db/seed_data.py create mode 100644 scripts/service/dispatcher.py create mode 100644 scripts/service/html_renderer.py create mode 100644 scripts/service/scanner.py create mode 100644 scripts/service/task_generator.py create mode 100644 scripts/templates/task.html.j2 create mode 100644 scripts/util/constants.py create mode 100644 scripts/util/date_util.py create mode 100644 scripts/util/logging_setup.py create mode 100644 scripts/util/runtime_paths.py diff --git a/.env.example b/.env.example index 97ddf9a..a342ea0 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,14 @@ # 合同到期智能预警与续聘辅助 - 配置示例 # 行头说明「是什么」;行尾说明「怎么填」;进程环境变量 > 用户 .env > 本文件 +# 本地路径配置(宿主环境由 jiangchang_skill_core 提供) +# SQLite DB 文件路径(默认 {skill_root}/data/alert-contract-expiring-hr.db) +DB_PATH= +# HTML 输出目录(默认 {skill_root}/output) +HTML_OUTPUT_DIR= +# 数据根(默认 {skill_root}/data) +JIANGCHANG_DATA_DIR= + # 人事系统 API 入口,例如 https://hr.example.edu/api/v1 HR_API_BASE_URL= @@ -28,5 +36,6 @@ DAILY_SCAN_AT=02:00 # 全校扫描超时(秒),默认 120 SCAN_TIMEOUT_SEC=120 -# 错误码前缀,统一为 ERROR: 起头 +# 日志级别 LOG_LEVEL=INFO + diff --git a/.gitignore b/.gitignore index 08d4ec6..3394a11 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,12 @@ __pycache__/ htmlcov/ dist/ build/ -*.egg-info/ \ No newline at end of file +*.egg-info/ + +# 运行时产物(不入库) +data/ +output/ +*.db +*.db-journal +*.sqlite +*.sqlite3 diff --git a/CHANGELOG.md b/CHANGELOG.md index ede09be..43ec83c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ 本文件对应技能市场 **「更新日志」** Tab。 +## 1.1.0 + +- 实现核心业务逻辑(9 个 CLI 命令全部可运行) +- 新增 `scripts/cli/app.py`:argparse 子命令分发 +- 新增 `scripts/util/{constants,date_util,runtime_paths,logging_setup}.py` +- 新增 `scripts/db/{connection,seed_data,repository}.py`:SQLite + 5 张表 + 50 份样例合同 + 150 条业绩记录 +- 新增 `scripts/service/scanner.py`:扫描核心(90/60/30 三级预警 + 写 alerts + 生成任务) +- 新增 `scripts/service/task_generator.py`:规则版 AI 续聘建议(师德/考核/趋势三规则) +- 新增 `scripts/service/dispatcher.py`:按单位/月份派发 payload(dry-run 模式) +- 新增 `scripts/service/html_renderer.py`:Jinja2 任务书 HTML 渲染(无 jinja2 时降级到 str.replace) +- 新增 `scripts/templates/task.html.j2`:任务书模板(基于 assets/html/example-report.html) +- 新增 `examples/{contracts_sample,performance_sample}.csv`:本地样例数据 +- `scripts/main.py`:重写为降级模式(jiangchang_skill_core 可选) +- `.env.example`:新增 `DB_PATH` / `HTML_OUTPUT_DIR` / `JIANGCHANG_DATA_DIR` +- 验收:本地 `python scripts/main.py ` 9 个命令全部 exit code = 0 + ## 1.0.1 - 初版:基于匠厂 skill-template 主仓 v1.0.108 派生 diff --git a/SKILL.md b/SKILL.md index 6cf1433..ee7ced7 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 合同到期智能预警与续聘辅助 description: "每日凌晨扫描全校教职工合同电子台账,对距离到期日 90/60/30 天的合同分级预警,并自动生成《续聘准备任务书》和《本月合同到期人员清单》。" -version: 1.0.1 +version: 1.1.0 author: 深圳匠厂科技有限公司 metadata: openclaw: diff --git a/examples/contracts_sample.csv b/examples/contracts_sample.csv new file mode 100644 index 0000000..1d9d9dc --- /dev/null +++ b/examples/contracts_sample.csv @@ -0,0 +1,51 @@ +contract_id,employee_id,teacher_name,unit_id,unit_name,position,contract_type,contract_start,contract_end,trial_period_end,status +C001,E0001,朱华,U01,计算机学院,副教授,聘用合同,2023-08-29,2026-08-13,2024-08-28,已驳回 +C002,E0002,周超,U02,经管学院,副教授,聘用合同,2023-09-22,2026-08-25,2024-09-21,已驳回 +C003,E0003,徐磊敏,U03,马克思主义学院,副教授,劳动合同,2023-09-15,2026-08-07,2024-09-14,已驳回 +C004,E0004,李涛,U04,教务处,助教,聘用合同,2023-09-13,2026-08-15,2024-09-12,已驳回 +C005,E0005,赵秀英丽,U05,后勤处,讲师,劳动合同,2023-10-12,2026-08-11,2024-10-11,已驳回 +C006,E0006,陈明,U01,计算机学院,助教,聘用合同,2023-09-14,2026-09-25,2024-09-13,可续聘 +C007,E0007,胡洋军,U02,经管学院,教授,聘用合同,2023-08-31,2026-09-16,2024-08-30,可续聘 +C008,E0008,王杰平,U03,马克思主义学院,助教,劳动合同,2023-08-26,2026-09-30,2024-08-25,可续聘 +C009,E0009,吴艳,U04,教务处,副教授,聘用合同,2023-09-11,2026-09-26,2024-09-10,可续聘 +C010,E0010,陈刚磊,U05,后勤处,副教授,劳动合同,2023-09-19,2026-10-08,2024-09-18,可续聘 +C011,E0011,徐勇,U01,计算机学院,教授,劳动合同,2023-08-27,2026-09-23,2024-08-26,可续聘 +C012,E0012,何杰敏,U02,经管学院,教授,劳动合同,2023-10-08,2026-10-02,2024-10-07,可续聘 +C013,E0013,何艳娜,U03,马克思主义学院,助教,劳动合同,2023-09-18,2026-09-18,2024-09-17,可续聘 +C014,E0014,郭涛平,U04,教务处,讲师,聘用合同,2023-09-06,2026-10-07,2024-09-05,可续聘 +C015,E0015,胡勇静,U05,后勤处,助教,劳动合同,2023-08-22,2026-10-02,2024-08-21,可续聘 +C016,E0016,马娜强,U01,计算机学院,助教,聘用合同,2023-08-23,2026-10-20,2024-08-22,可续聘 +C017,E0017,高秀英艳,U02,经管学院,副教授,劳动合同,2023-08-17,2026-10-20,2024-08-16,可续聘 +C018,E0018,赵芳,U03,马克思主义学院,教授,聘用合同,2023-10-13,2026-10-23,2024-10-12,可续聘 +C019,E0019,林勇娜,U04,教务处,讲师,劳动合同,2023-09-10,2026-10-30,2024-09-09,可续聘 +C020,E0020,杨娜杰,U05,后勤处,助教,聘用合同,2023-10-11,2026-11-11,2024-10-10,可续聘 +C021,E0021,吴桂英,U01,计算机学院,讲师,劳动合同,2023-10-08,2026-11-03,2024-10-07,可续聘 +C022,E0022,林娟,U02,经管学院,副教授,聘用合同,2023-08-25,2026-11-08,2024-08-24,可续聘 +C023,E0023,朱娟,U03,马克思主义学院,教授,劳动合同,2023-09-11,2026-10-21,2024-09-10,可续聘 +C024,E0024,林杰超,U04,教务处,教授,劳动合同,2023-10-06,2026-11-09,2024-10-05,可续聘 +C025,E0025,黄静,U05,后勤处,副教授,聘用合同,2023-10-07,2026-11-02,2024-10-06,可续聘 +C026,E0026,李杰,U01,计算机学院,副教授,劳动合同,2023-09-27,2026-11-29,2024-09-26,可续聘 +C027,E0027,何磊勇,U02,经管学院,教授,聘用合同,2023-09-18,2026-11-26,2024-09-17,可续聘 +C028,E0028,吴静涛,U03,马克思主义学院,副教授,劳动合同,2023-09-07,2026-12-11,2024-09-06,可续聘 +C029,E0029,郭磊娟,U04,教务处,副教授,劳动合同,2023-10-03,2026-12-08,2024-10-02,可续聘 +C030,E0030,杨明强,U05,后勤处,助教,聘用合同,2023-09-11,2026-11-26,2024-09-10,可续聘 +C031,E0031,王杰涛,U01,计算机学院,讲师,劳动合同,2023-08-18,2026-12-05,2024-08-17,可续聘 +C032,E0032,朱涛华,U02,经管学院,教授,劳动合同,2023-08-31,2026-11-21,2024-08-30,可续聘 +C033,E0033,罗超,U03,马克思主义学院,副教授,劳动合同,2023-09-03,2026-12-11,2024-09-02,可续聘 +C034,E0034,杨强,U04,教务处,教授,劳动合同,2023-09-28,2026-11-16,2024-09-27,可续聘 +C035,E0035,高磊,U05,后勤处,副教授,聘用合同,2023-10-01,2026-11-27,2024-09-30,可续聘 +C036,E0036,黄洋平,U01,计算机学院,助教,劳动合同,2023-08-24,2027-02-23,2024-08-23,已续签 +C037,E0037,徐明芳,U02,经管学院,讲师,聘用合同,2023-09-26,2026-12-19,2024-09-25,已续签 +C038,E0038,赵伟超,U03,马克思主义学院,助教,聘用合同,2023-10-11,2027-03-01,2024-10-10,已续签 +C039,E0039,罗伟,U04,教务处,讲师,聘用合同,2023-08-30,2027-04-02,2024-08-29,可续聘 +C040,E0040,郭明霞,U05,后勤处,教授,劳动合同,2023-09-08,2027-02-02,2024-09-07,可续聘 +C041,E0041,周芳霞,U01,计算机学院,助教,劳动合同,2023-10-09,2027-05-13,2024-10-08,可续聘 +C042,E0042,黄丽,U02,经管学院,副教授,劳动合同,2023-10-05,2027-07-16,2024-10-04,可续聘 +C043,E0043,王艳刚,U03,马克思主义学院,教授,劳动合同,2023-10-14,2027-08-28,2024-10-13,可续聘 +C044,E0044,朱杰桂英,U04,教务处,副教授,劳动合同,2023-08-24,2027-04-16,2024-08-23,可续聘 +C045,E0045,张勇明,U05,后勤处,教授,劳动合同,2023-09-20,2027-08-06,2024-09-19,可续聘 +C046,E0046,罗秀英,U01,计算机学院,讲师,聘用合同,2023-09-30,2027-07-26,2024-09-29,可续聘 +C047,E0047,王伟,U02,经管学院,副教授,劳动合同,2023-09-03,2026-12-22,2024-09-02,可续聘 +C048,E0048,刘鹏娜,U03,马克思主义学院,讲师,聘用合同,2023-09-08,2027-02-25,2024-09-07,可续聘 +C049,E0049,黄娜鹏,U04,教务处,副教授,聘用合同,2023-10-13,2027-07-16,2024-10-12,可续聘 +C050,E0050,马涛强,U05,后勤处,副教授,劳动合同,2023-09-02,2027-06-21,2024-09-01,可续聘 diff --git a/examples/performance_sample.csv b/examples/performance_sample.csv new file mode 100644 index 0000000..962ab1b --- /dev/null +++ b/examples/performance_sample.csv @@ -0,0 +1,151 @@ +record_id,contract_id,year,performance_score,moral_teaching_score,research_score,teaching_score +R0001,C001,2023,89,93,93,84 +R0002,C001,2024,87,93,84,86 +R0003,C001,2025,77,93,71,79 +R0004,C002,2023,75,93,84,70 +R0005,C002,2024,80,93,90,80 +R0006,C002,2025,95,93,89,98 +R0007,C003,2023,80,86,77,81 +R0008,C003,2024,76,86,80,78 +R0009,C003,2025,93,86,87,88 +R0010,C004,2023,88,78,90,90 +R0011,C004,2024,90,78,91,88 +R0012,C004,2025,77,78,77,77 +R0013,C005,2023,92,97,91,88 +R0014,C005,2024,82,97,76,86 +R0015,C005,2025,76,97,83,81 +R0016,C006,2023,92,99,98,91 +R0017,C006,2024,93,99,91,88 +R0018,C006,2025,81,99,80,85 +R0019,C007,2023,79,93,69,74 +R0020,C007,2024,76,93,70,75 +R0021,C007,2025,91,93,96,88 +R0022,C008,2023,75,91,82,78 +R0023,C008,2024,75,91,74,80 +R0024,C008,2025,95,91,98,94 +R0025,C009,2023,78,87,84,82 +R0026,C009,2024,86,87,96,86 +R0027,C009,2025,95,87,100,94 +R0028,C010,2023,84,80,78,80 +R0029,C010,2024,79,80,81,76 +R0030,C010,2025,93,80,99,95 +R0031,C011,2023,83,88,81,78 +R0032,C011,2024,78,88,76,75 +R0033,C011,2025,84,88,93,79 +R0034,C012,2023,91,88,83,93 +R0035,C012,2024,82,88,77,81 +R0036,C012,2025,77,88,79,75 +R0037,C013,2023,77,98,82,77 +R0038,C013,2024,76,98,85,73 +R0039,C013,2025,85,98,94,80 +R0040,C014,2023,91,87,96,92 +R0041,C014,2024,85,87,90,90 +R0042,C014,2025,93,87,84,91 +R0043,C015,2023,86,88,84,86 +R0044,C015,2024,79,88,71,79 +R0045,C015,2025,91,88,83,95 +R0046,C016,2023,94,78,94,93 +R0047,C016,2024,90,78,88,85 +R0048,C016,2025,55,78,55,51 +R0049,C017,2023,93,78,96,92 +R0050,C017,2024,79,78,86,78 +R0051,C017,2025,85,78,76,85 +R0052,C018,2023,80,76,77,79 +R0053,C018,2024,84,76,77,83 +R0054,C018,2025,95,76,88,100 +R0055,C019,2023,85,81,90,89 +R0056,C019,2024,86,81,79,84 +R0057,C019,2025,94,81,93,95 +R0058,C020,2023,82,76,82,78 +R0059,C020,2024,90,76,92,89 +R0060,C020,2025,75,76,84,74 +R0061,C021,2023,83,90,77,79 +R0062,C021,2024,79,90,85,81 +R0063,C021,2025,77,90,74,79 +R0064,C022,2023,93,76,97,96 +R0065,C022,2024,81,76,85,86 +R0066,C022,2025,91,76,86,94 +R0067,C023,2023,85,99,82,81 +R0068,C023,2024,86,99,89,82 +R0069,C023,2025,83,99,77,86 +R0070,C024,2023,77,95,74,77 +R0071,C024,2024,78,95,72,77 +R0072,C024,2025,89,95,88,94 +R0073,C025,2023,75,97,68,80 +R0074,C025,2024,82,97,78,87 +R0075,C025,2025,90,97,98,94 +R0076,C026,2023,79,75,75,74 +R0077,C026,2024,93,75,84,91 +R0078,C026,2025,90,75,80,87 +R0079,C027,2023,80,76,75,85 +R0080,C027,2024,94,76,98,95 +R0081,C027,2025,77,76,74,77 +R0082,C028,2023,83,76,92,81 +R0083,C028,2024,79,76,74,83 +R0084,C028,2025,81,76,86,77 +R0085,C029,2023,90,84,80,95 +R0086,C029,2024,81,84,76,77 +R0087,C029,2025,75,84,65,75 +R0088,C030,2023,92,79,90,95 +R0089,C030,2024,84,79,75,86 +R0090,C030,2025,94,79,96,95 +R0091,C031,2023,90,91,93,93 +R0092,C031,2024,79,91,83,84 +R0093,C031,2025,84,91,85,87 +R0094,C032,2023,85,88,78,82 +R0095,C032,2024,92,88,90,95 +R0096,C032,2025,91,88,99,91 +R0097,C033,2023,88,98,90,88 +R0098,C033,2024,83,98,90,87 +R0099,C033,2025,86,98,85,81 +R0100,C034,2023,80,97,83,84 +R0101,C034,2024,94,97,90,89 +R0102,C034,2025,95,97,86,96 +R0103,C035,2023,82,76,90,79 +R0104,C035,2024,90,76,86,95 +R0105,C035,2025,77,76,73,73 +R0106,C036,2023,83,91,83,87 +R0107,C036,2024,95,91,100,98 +R0108,C036,2025,76,91,86,74 +R0109,C037,2023,79,98,78,84 +R0110,C037,2024,94,98,93,98 +R0111,C037,2025,75,98,67,74 +R0112,C038,2023,95,89,86,96 +R0113,C038,2024,87,89,87,87 +R0114,C038,2025,92,89,88,92 +R0115,C039,2023,90,91,96,91 +R0116,C039,2024,86,91,79,89 +R0117,C039,2025,81,91,84,82 +R0118,C040,2023,76,98,84,81 +R0119,C040,2024,82,98,72,80 +R0120,C040,2025,91,98,84,92 +R0121,C041,2023,84,99,76,81 +R0122,C041,2024,85,99,78,83 +R0123,C041,2025,81,99,79,82 +R0124,C042,2023,82,99,86,78 +R0125,C042,2024,80,99,70,79 +R0126,C042,2025,79,99,79,78 +R0127,C043,2023,94,82,98,99 +R0128,C043,2024,77,82,71,78 +R0129,C043,2025,91,82,97,93 +R0130,C044,2023,75,82,79,73 +R0131,C044,2024,78,82,73,83 +R0132,C044,2025,88,82,79,83 +R0133,C045,2023,93,94,100,97 +R0134,C045,2024,76,94,77,73 +R0135,C045,2025,79,94,82,80 +R0136,C046,2023,81,86,71,86 +R0137,C046,2024,82,86,82,82 +R0138,C046,2025,78,86,82,76 +R0139,C047,2023,79,83,73,78 +R0140,C047,2024,89,83,97,86 +R0141,C047,2025,92,83,90,93 +R0142,C048,2023,92,81,87,91 +R0143,C048,2024,84,81,89,83 +R0144,C048,2025,85,81,78,81 +R0145,C049,2023,81,86,80,80 +R0146,C049,2024,77,86,80,82 +R0147,C049,2025,87,86,93,83 +R0148,C050,2023,76,96,82,75 +R0149,C050,2024,94,96,93,92 +R0150,C050,2025,93,96,100,93 diff --git a/scripts/cli/app.py b/scripts/cli/app.py new file mode 100644 index 0000000..6895e2c --- /dev/null +++ b/scripts/cli/app.py @@ -0,0 +1,274 @@ +"""CLI 命令分发:argparse 子命令 → 调用 service 层。 + +9 个子命令(与 assets/actions.json 对齐): +- health +- version +- config-path +- init-db +- scan-expiring-contracts +- generate-renewal-task +- dispatch-task-notice +- mark-contract-renewed +- render-html +""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import Callable, List, Optional + +from db.connection import get_connection, init_db +from service import dispatcher, html_renderer, scanner, task_generator +from util.constants import DEFAULT_THRESHOLDS, SKILL_VERSION +from util.logging_setup import get_skill_logger, setup_skill_logging +from util.runtime_paths import ( + get_db_path, + get_dispatch_dir, + get_env_path, + get_output_dir, +) + +logger = get_skill_logger() + + +# ---------- 命令实现 ---------- + +def cmd_health(_args: argparse.Namespace) -> int: + """健康检查。""" + setup_skill_logging() + db_path = get_db_path() + db_exists = db_path.exists() + db_writable = False + error_msg = None + + try: + conn = get_connection() + try: + conn.execute("SELECT 1").fetchone() + db_writable = True + finally: + conn.close() + except Exception as e: + error_msg = str(e) + + output_dir = get_output_dir() + dispatch_dir = get_dispatch_dir() + checks = [ + ("python_version", f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", True), + ("db_path", str(db_path), True), + ("db_exists", str(db_exists), db_exists), + ("db_writable", str(db_writable), db_writable), + ("output_dir", str(output_dir), True), + ("dispatch_dir", str(dispatch_dir), True), + ] + + if db_exists and db_writable: + try: + conn = get_connection() + try: + counts = { + "contracts": conn.execute("SELECT COUNT(*) AS n FROM contracts").fetchone()["n"], + "performance_records": conn.execute("SELECT COUNT(*) AS n FROM performance_records").fetchone()["n"], + "alerts": conn.execute("SELECT COUNT(*) AS n FROM alerts").fetchone()["n"], + "renewal_tasks": conn.execute("SELECT COUNT(*) AS n FROM renewal_tasks").fetchone()["n"], + "dispatch_log": conn.execute("SELECT COUNT(*) AS n FROM dispatch_log").fetchone()["n"], + } + finally: + conn.close() + checks.append(("seed_loaded", f"contracts={counts['contracts']} perf={counts['performance_records']}", counts["contracts"] > 0)) + except Exception as e: + checks.append(("seed_check", f"ERROR: {e}", False)) + + all_ok = all(c[2] for c in checks) + for name, val, ok in checks: + marker = "OK " if ok else "FAIL" + print(f"[{marker}] {name}: {val}") + if error_msg: + print(f"[FAIL] error: {error_msg}") + + if all_ok: + print("OK: health check passed") + return 0 + print("ERROR: health check failed") + return 1 + + +def cmd_version(_args: argparse.Namespace) -> int: + print(f"{SKILL_VERSION}") + return 0 + + +def cmd_config_path(_args: argparse.Namespace) -> int: + print(get_env_path()) + return 0 + + +def cmd_init_db(_args: argparse.Namespace) -> int: + counts = init_db() + print(f"OK: init-db complete") + for k, v in counts.items(): + print(f" {k}: {v} rows") + return 0 + + +def cmd_scan(args: argparse.Namespace) -> int: + thresholds = _parse_thresholds(args.thresholds) + conn = get_connection() + try: + result = scanner.scan(conn, thresholds=thresholds) + finally: + conn.close() + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +def cmd_generate_task(args: argparse.Namespace) -> int: + contract_id = args.contract_id + task_id = args.task_id + conn = get_connection() + try: + result = task_generator.regenerate_task_for_contract_id(conn, contract_id, task_id) + conn.commit() + except ValueError as e: + print(f"ERROR: {e}") + return 2 + finally: + conn.close() + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +def cmd_dispatch(args: argparse.Namespace) -> int: + conn = get_connection() + try: + result = dispatcher.dispatch(conn, args.unit_id, args.month or "") + finally: + conn.close() + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +def cmd_mark_renewed(args: argparse.Namespace) -> int: + conn = get_connection() + try: + from db import repository as repo + + contract = repo.get_contract(conn, args.contract_id) + if contract is None: + print(f"ERROR: contract_id={args.contract_id!r} 不存在") + return 2 + + with conn: + n = repo.update_contract_status(conn, args.contract_id, "已续签", args.material_id) + closed = repo.close_open_alerts_for_contract(conn, args.contract_id) + print(json.dumps({ + "contract_id": args.contract_id, + "status": "已续签", + "material_id": args.material_id, + "updated_rows": n, + "closed_alerts": closed, + }, ensure_ascii=False, indent=2)) + finally: + conn.close() + return 0 + + +def cmd_render_html(args: argparse.Namespace) -> int: + conn = get_connection() + try: + output_path = html_renderer.render_task_html(conn, args.task_id) + except ValueError as e: + print(f"ERROR: {e}") + return 2 + except FileNotFoundError as e: + print(f"ERROR: {e}") + return 3 + finally: + conn.close() + print(json.dumps({ + "task_id": args.task_id, + "output_path": str(output_path), + "size_bytes": output_path.stat().st_size, + }, ensure_ascii=False, indent=2)) + return 0 + + +# ---------- argparse ---------- + +def _parse_thresholds(s: str) -> tuple[int, int, int]: + parts = [int(p.strip()) for p in s.split(",")] + if len(parts) != 3 or sorted(parts, reverse=True) != parts: + raise argparse.ArgumentTypeError( + f"thresholds 必须是 3 个整数(黄,橙,红)且从大到小,例如 90,60,30;收到 {s!r}" + ) + return (parts[0], parts[1], parts[2]) + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="main.py", + description=f"合同到期智能预警与续聘辅助 v{SKILL_VERSION}", + ) + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser("health", help="健康检查(Python / DB / 目录)").set_defaults(handler=cmd_health) + sub.add_parser("version", help="查看版本").set_defaults(handler=cmd_version) + sub.add_parser("config-path", help="查看 .env 路径").set_defaults(handler=cmd_config_path) + sub.add_parser("init-db", help="建表 + 加载样例数据").set_defaults(handler=cmd_init_db) + + sp = sub.add_parser( + "scan-expiring-contracts", help="扫描即将到期合同(async/cron)", + ) + sp.add_argument( + "--thresholds", default="90,60,30", + help="三级预警阈值(黄,橙,红)从大到小,默认 90,60,30", + ) + sp.set_defaults(handler=cmd_scan) + + sp = sub.add_parser( + "generate-renewal-task", help="为指定合同生成续聘任务书", + ) + sp.add_argument("--contract-id", required=True, help="合同 ID") + sp.add_argument("--task-id", default=None, help="可选:指定 task_id(默认自动生成)") + sp.set_defaults(handler=cmd_generate_task) + + sp = sub.add_parser( + "dispatch-task-notice", help="按单位/月份派发续聘通知(dry-run)", + ) + sp.add_argument("--unit-id", required=True, help="二级单位 ID") + sp.add_argument("--month", default="", help="月份 YYYY-MM(留空 = 全部)") + sp.set_defaults(handler=cmd_dispatch) + + sp = sub.add_parser( + "mark-contract-renewed", help="标记合同已续签", + ) + sp.add_argument("--contract-id", required=True, help="合同 ID") + sp.add_argument("--material-id", required=True, help="续签材料 ID") + sp.set_defaults(handler=cmd_mark_renewed) + + sp = sub.add_parser( + "render-html", help="渲染任务书 HTML", + ) + sp.add_argument("--task-id", required=True, help="任务 ID") + sp.set_defaults(handler=cmd_render_html) + + return p + + +def main(argv: Optional[List[str]] = None) -> int: + argv = argv if argv is not None else sys.argv[1:] + if not argv: + print(f"合同到期智能预警与续聘辅助 v{SKILL_VERSION}") + print("Commands: health, version, config-path, init-db, " + "scan-expiring-contracts, generate-renewal-task, " + "dispatch-task-notice, mark-contract-renewed, render-html") + return 1 + + setup_skill_logging() + + parser = build_parser() + args = parser.parse_args(argv) + handler: Callable[[argparse.Namespace], int] = args.handler + return handler(args) diff --git a/scripts/db/connection.py b/scripts/db/connection.py new file mode 100644 index 0000000..0d5b16b --- /dev/null +++ b/scripts/db/connection.py @@ -0,0 +1,134 @@ +"""SQLite 连接 + DDL 定义 + init_db()。 + +5 张表: +- contracts: 合同台账 +- performance_records: 近 3 年业绩记录 +- alerts: 预警记录 +- renewal_tasks: 续聘任务 +- dispatch_log: 派发日志 +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path +from typing import Iterable + +from util.runtime_paths import ensure_dirs, get_db_path + +# DDL:所有 IF NOT EXISTS,幂等可重入 +DDL_STATEMENTS: tuple[str, ...] = ( + """ + CREATE TABLE IF NOT EXISTS contracts ( + contract_id TEXT PRIMARY KEY, + employee_id TEXT NOT NULL, + teacher_name TEXT NOT NULL, + unit_id TEXT NOT NULL, + unit_name TEXT NOT NULL, + position TEXT NOT NULL, + contract_type TEXT NOT NULL, + contract_start TEXT NOT NULL, + contract_end TEXT NOT NULL, + trial_period_end TEXT NOT NULL, + status TEXT NOT NULL DEFAULT '可续聘', + last_material_id TEXT, + renewed_at TEXT + ); + """, + """ + CREATE TABLE IF NOT EXISTS performance_records ( + record_id TEXT PRIMARY KEY, + contract_id TEXT NOT NULL, + year INTEGER NOT NULL, + performance_score REAL NOT NULL, + moral_teaching_score REAL NOT NULL, + research_score REAL NOT NULL, + teaching_score REAL NOT NULL, + FOREIGN KEY (contract_id) REFERENCES contracts(contract_id) + ); + """, + "CREATE INDEX IF NOT EXISTS idx_perf_contract ON performance_records(contract_id);", + """ + CREATE TABLE IF NOT EXISTS alerts ( + alert_id TEXT PRIMARY KEY, + contract_id TEXT NOT NULL, + alert_level TEXT NOT NULL, + days_to_expire INTEGER NOT NULL, + scan_id TEXT NOT NULL, + generated_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + FOREIGN KEY (contract_id) REFERENCES contracts(contract_id) + ); + """, + "CREATE INDEX IF NOT EXISTS idx_alerts_contract ON alerts(contract_id);", + "CREATE INDEX IF NOT EXISTS idx_alerts_status ON alerts(status);", + """ + CREATE TABLE IF NOT EXISTS renewal_tasks ( + task_id TEXT PRIMARY KEY, + contract_id TEXT NOT NULL, + alert_id TEXT, + ai_suggestion TEXT NOT NULL, + suggestion_reason TEXT NOT NULL, + performance_summary TEXT NOT NULL, + materials_checklist TEXT NOT NULL, + generated_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + FOREIGN KEY (contract_id) REFERENCES contracts(contract_id), + FOREIGN KEY (alert_id) REFERENCES alerts(alert_id) + ); + """, + "CREATE INDEX IF NOT EXISTS idx_tasks_contract ON renewal_tasks(contract_id);", + """ + CREATE TABLE IF NOT EXISTS dispatch_log ( + log_id TEXT PRIMARY KEY, + unit_id TEXT NOT NULL, + month TEXT NOT NULL, + task_ids TEXT NOT NULL, + dispatched_at TEXT NOT NULL, + payload_path TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'dry_run' + ); + """, + "CREATE INDEX IF NOT EXISTS idx_dispatch_unit_month ON dispatch_log(unit_id, month);", +) + + +def get_connection(db_path: Path | None = None) -> sqlite3.Connection: + """获取 SQLite 连接。""" + ensure_dirs() + path = db_path or get_db_path() + conn = sqlite3.connect(str(path)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON;") + return conn + + +def init_db(db_path: Path | None = None) -> dict[str, int]: + """建表 + 加载种子数据。返回表行数 dict。""" + ensure_dirs() + conn = get_connection(db_path) + try: + for ddl in DDL_STATEMENTS: + conn.execute(ddl) + conn.commit() + + # 加载种子(如果 contracts 表为空) + from db.seed_data import load_seed_if_empty + + load_seed_if_empty(conn) + + # 统计 + counts = { + "contracts": conn.execute("SELECT COUNT(*) AS n FROM contracts").fetchone()["n"], + "performance_records": conn.execute("SELECT COUNT(*) AS n FROM performance_records").fetchone()["n"], + "alerts": conn.execute("SELECT COUNT(*) AS n FROM alerts").fetchone()["n"], + "renewal_tasks": conn.execute("SELECT COUNT(*) AS n FROM renewal_tasks").fetchone()["n"], + "dispatch_log": conn.execute("SELECT COUNT(*) AS n FROM dispatch_log").fetchone()["n"], + } + return counts + finally: + conn.close() + + +def table_names() -> Iterable[str]: + return ("contracts", "performance_records", "alerts", "renewal_tasks", "dispatch_log") diff --git a/scripts/db/repository.py b/scripts/db/repository.py new file mode 100644 index 0000000..8a72392 --- /dev/null +++ b/scripts/db/repository.py @@ -0,0 +1,195 @@ +"""CRUD 仓储:contracts / performance_records / alerts / renewal_tasks / dispatch_log。 + +所有方法接收一个外部传入的 sqlite3.Connection,调用方负责事务边界。 +""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import date, datetime +from typing import Any, Iterable + + +# ---------- contracts ---------- + +def list_active_contracts(conn: sqlite3.Connection) -> list[sqlite3.Row]: + """列出 status='可续聘' 的合同。""" + return conn.execute( + "SELECT * FROM contracts WHERE status = '可续聘' ORDER BY contract_end ASC" + ).fetchall() + + +def get_contract(conn: sqlite3.Connection, contract_id: str) -> sqlite3.Row | None: + return conn.execute( + "SELECT * FROM contracts WHERE contract_id = ?", (contract_id,) + ).fetchone() + + +def update_contract_status( + conn: sqlite3.Connection, + contract_id: str, + status: str, + material_id: str | None = None, +) -> int: + """更新合同状态。material_id 仅在 status='已续签' 时写入。""" + renewed_at = datetime.now().isoformat(timespec="seconds") if status == "已续签" else None + cur = conn.execute( + """ + UPDATE contracts + SET status = ?, + last_material_id = COALESCE(?, last_material_id), + renewed_at = COALESCE(?, renewed_at) + WHERE contract_id = ? + """, + (status, material_id, renewed_at, contract_id), + ) + return cur.rowcount + + +# ---------- performance ---------- + +def list_performance_by_contract( + conn: sqlite3.Connection, contract_id: str +) -> list[sqlite3.Row]: + """某合同的业绩记录,按年份升序。""" + return conn.execute( + "SELECT * FROM performance_records WHERE contract_id = ? ORDER BY year ASC", + (contract_id,), + ).fetchall() + + +# ---------- alerts ---------- + +def insert_alert( + conn: sqlite3.Connection, + alert_id: str, + contract_id: str, + level: str, + days_to_expire: int, + scan_id: str, +) -> None: + conn.execute( + """ + INSERT OR REPLACE INTO alerts + (alert_id, contract_id, alert_level, days_to_expire, scan_id, generated_at, status) + VALUES (?, ?, ?, ?, ?, ?, 'open') + """, + ( + alert_id, + contract_id, + level, + days_to_expire, + scan_id, + datetime.now().isoformat(timespec="seconds"), + ), + ) + + +def close_open_alerts_for_contract(conn: sqlite3.Connection, contract_id: str) -> int: + """将某合同所有 open 状态的 alerts 关闭(关联 '已续签' 等终态)。""" + cur = conn.execute( + "UPDATE alerts SET status = 'closed' WHERE contract_id = ? AND status = 'open'", + (contract_id,), + ) + return cur.rowcount + + +def list_open_alerts_by_unit_month( + conn: sqlite3.Connection, unit_id: str, month: str +) -> list[sqlite3.Row]: + """按单位/月份列出关联合同的 open alerts(用于派发汇总)。""" + sql = """ + SELECT a.alert_id, a.contract_id, a.alert_level, a.days_to_expire, + c.contract_end, c.teacher_name, c.unit_name + FROM alerts a + JOIN contracts c ON c.contract_id = a.contract_id + WHERE a.status = 'open' + AND c.unit_id = ? + """ + params: list[Any] = [unit_id] + if month: + sql += " AND substr(c.contract_end, 1, 7) = ?" + params.append(month) + sql += " ORDER BY c.contract_end ASC" + return conn.execute(sql, params).fetchall() + + +# ---------- renewal_tasks ---------- + +def insert_task( + conn: sqlite3.Connection, + task_id: str, + contract_id: str, + alert_id: str, + suggestion: str, + reason: str, + summary: str, + checklist: list[str], +) -> None: + conn.execute( + """ + INSERT OR REPLACE INTO renewal_tasks + (task_id, contract_id, alert_id, ai_suggestion, suggestion_reason, + performance_summary, materials_checklist, generated_at, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending') + """, + ( + task_id, + contract_id, + alert_id, + suggestion, + reason, + summary, + json.dumps(checklist, ensure_ascii=False), + datetime.now().isoformat(timespec="seconds"), + ), + ) + + +def get_task(conn: sqlite3.Connection, task_id: str) -> sqlite3.Row | None: + return conn.execute( + """ + SELECT t.*, c.teacher_name, c.unit_name, c.position, c.contract_type, + c.contract_start, c.contract_end, c.employee_id + FROM renewal_tasks t + JOIN contracts c ON c.contract_id = t.contract_id + WHERE t.task_id = ? + """, + (task_id,), + ).fetchone() + + +# ---------- dispatch_log ---------- + +def insert_dispatch_log( + conn: sqlite3.Connection, + log_id: str, + unit_id: str, + month: str, + task_ids: list[str], + payload_path: str, + status: str = "dry_run", +) -> None: + conn.execute( + """ + INSERT INTO dispatch_log + (log_id, unit_id, month, task_ids, dispatched_at, payload_path, status) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + log_id, + unit_id, + month, + json.dumps(task_ids, ensure_ascii=False), + datetime.now().isoformat(timespec="seconds"), + payload_path, + status, + ), + ) + + +def list_dispatch_logs(conn: sqlite3.Connection) -> list[sqlite3.Row]: + return conn.execute( + "SELECT * FROM dispatch_log ORDER BY dispatched_at DESC" + ).fetchall() diff --git a/scripts/db/seed_data.py b/scripts/db/seed_data.py new file mode 100644 index 0000000..be8ef3b --- /dev/null +++ b/scripts/db/seed_data.py @@ -0,0 +1,90 @@ +"""种子数据加载:从 examples/contracts_sample.csv 和 performance_sample.csv 读入。 + +只在 contracts 表为空时执行,幂等。 +真实部署应替换为人事系统 API 调用。 +""" + +from __future__ import annotations + +import csv +import sqlite3 +from pathlib import Path + +from util.logging_setup import get_skill_logger +from util.runtime_paths import get_examples_dir + +logger = get_skill_logger() + + +def _read_csv(path: Path) -> list[dict[str, str]]: + with open(path, "r", encoding="utf-8", newline="") as f: + return list(csv.DictReader(f)) + + +def load_seed_if_empty(conn: sqlite3.Connection) -> tuple[int, int]: + """如果 contracts 表为空,从 CSV 加载种子数据。返回 (合同数, 业绩数)。""" + n = conn.execute("SELECT COUNT(*) AS n FROM contracts").fetchone()["n"] + if n > 0: + logger.info("seed_data: contracts 表已有 %d 条记录,跳过加载", n) + return (n, 0) + + examples_dir = get_examples_dir() + contracts_csv = examples_dir / "contracts_sample.csv" + performance_csv = examples_dir / "performance_sample.csv" + + if not contracts_csv.exists() or not performance_csv.exists(): + logger.warning("seed_data: 样例 CSV 不存在 %s / %s", contracts_csv, performance_csv) + return (0, 0) + + contracts = _read_csv(contracts_csv) + performance = _read_csv(performance_csv) + + with conn: + conn.executemany( + """ + INSERT OR REPLACE INTO contracts + (contract_id, employee_id, teacher_name, unit_id, unit_name, + position, contract_type, contract_start, contract_end, + trial_period_end, status, last_material_id, renewed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL) + """, + [ + ( + c["contract_id"], + c["employee_id"], + c["teacher_name"], + c["unit_id"], + c["unit_name"], + c["position"], + c["contract_type"], + c["contract_start"], + c["contract_end"], + c["trial_period_end"], + c["status"], + ) + for c in contracts + ], + ) + conn.executemany( + """ + INSERT OR REPLACE INTO performance_records + (record_id, contract_id, year, performance_score, + moral_teaching_score, research_score, teaching_score) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + [ + ( + p["record_id"], + p["contract_id"], + int(p["year"]), + float(p["performance_score"]), + float(p["moral_teaching_score"]), + float(p["research_score"]), + float(p["teaching_score"]), + ) + for p in performance + ], + ) + + logger.info("seed_data: 加载 %d 份合同 / %d 条业绩记录", len(contracts), len(performance)) + return (len(contracts), len(performance)) diff --git a/scripts/main.py b/scripts/main.py index c4e60ac..881271b 100644 --- a/scripts/main.py +++ b/scripts/main.py @@ -1,91 +1,45 @@ #!/usr/bin/env python3 -"""合同到期智能预警与续聘辅助 - CLI 入口(占位骨架)""" +# -*- coding: utf-8 -*- +"""合同到期智能预警与续聘辅助 - CLI 入口。 +v1.1.0 实现: +- argparse 子命令分发 +- jiangchang_skill_core 降级(宿主环境用完整版,本地用自包含实现) +- UTF-8 输出兼容(Windows cmd) +""" + +from __future__ import annotations + +import io +import os import sys -from pathlib import Path -# 占位:开发者后续接入 jiangchang_skill_core -# from jiangchang_skill_core import ... +# UTF-8 输出兼容(Windows) +if sys.platform == "win32": + if hasattr(sys.stdout, "buffer"): + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + if hasattr(sys.stderr, "buffer"): + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") -BASE_DIR = Path(__file__).resolve().parent.parent +# scripts/main.py → scripts/ → 加进 sys.path +_scripts_dir = os.path.dirname(os.path.abspath(__file__)) +if _scripts_dir not in sys.path: + sys.path.insert(0, _scripts_dir) -def health(): - """运行检查(占位)。""" - print(f"alert-contract-expiring-hr health check (placeholder)") - print(f"base_dir: {BASE_DIR}") - return 0 +def _apply_host_runtime() -> None: + """宿主共享 runtime 集成(本地无 jiangchang_skill_core 时跳过)。""" + try: + from jiangchang_skill_core.runtime_env import apply_cli_local_defaults + + apply_cli_local_defaults() + except ImportError: + pass # 本地环境没有宿主 runtime,跳过 -def version(): - """查看版本(占位)。""" - print("alert-contract-expiring-hr v1.0.1") - return 0 - - -def config_path(): - """查看配置位置(占位)。""" - print(f"{BASE_DIR / '.env'}") - return 0 - - -def init_db(): - """初始化数据库(占位)。""" - print("init-db: 后续由开发者按 references/SCHEMA.md 实现") - return 0 - - -def scan_expiring_contracts(thresholds: str = "90,60,30"): - """扫描即将到期合同(async)。""" - print(f"scan-expiring-contracts: thresholds={thresholds} (待开发者实现)") - return 0 - - -def generate_renewal_task(contract_id: str): - """生成续聘任务书。""" - print(f"generate-renewal-task: contract_id={contract_id} (待开发者实现)") - return 0 - - -def dispatch_task_notice(unit_id: str = "", month: str = ""): - """下发续聘任务通知单。""" - print(f"dispatch-task-notice: unit_id={unit_id} month={month} (待开发者实现)") - return 0 - - -def mark_contract_renewed(contract_id: str, material_id: str): - """标记合同已续签。""" - print(f"mark-contract-renewed: contract_id={contract_id} material_id={material_id} (待开发者实现)") - return 0 - - -def render_html(task_id: str): - """在浏览器中打开续聘任务书。""" - print(f"render-html: task_id={task_id} (待开发者实现)") - return 0 - - -COMMANDS = { - "health": health, - "version": version, - "config-path": config_path, - "init-db": init_db, - "scan-expiring-contracts": scan_expiring_contracts, - "generate-renewal-task": generate_renewal_task, - "dispatch-task-notice": dispatch_task_notice, - "mark-contract-renewed": mark_contract_renewed, - "render-html": render_html, -} - - -def main(argv=None): - argv = argv or sys.argv[1:] - if not argv or argv[0] not in COMMANDS: - print("Usage: python scripts/main.py ") - print("Commands:", ", ".join(COMMANDS.keys())) - return 1 - return COMMANDS[argv[0]](*argv[1:]) +_apply_host_runtime() +from cli.app import main if __name__ == "__main__": - sys.exit(main()) + raise SystemExit(main()) diff --git a/scripts/service/dispatcher.py b/scripts/service/dispatcher.py new file mode 100644 index 0000000..c774c50 --- /dev/null +++ b/scripts/service/dispatcher.py @@ -0,0 +1,123 @@ +"""派发器:按单位/月份汇总任务 → 生成派发 payload JSON → 写派发日志。 + +v1.1.0 dry-run 模式:payload 写 data/dispatch/{unit}_{month}.json, +但**不真正调用企业微信 API**。开发者后续接入: +- 真实 API:调用 WECOM_API_BASE_URL + WECOM_API_TOKEN +- 在 dispatch_log 写 status='sent' 而非 'dry_run' +""" + +from __future__ import annotations + +import json +import sqlite3 +import uuid +from datetime import datetime +from pathlib import Path + +from db import repository as repo +from util.date_util import parse_iso_date +from util.logging_setup import get_skill_logger +from util.runtime_paths import get_dispatch_dir + +logger = get_skill_logger() + + +def build_payload( + conn: sqlite3.Connection, + unit_id: str, + month: str = "", +) -> dict: + """构建派发 payload:含单位、月份、任务清单、收件人、消息正文。""" + rows = repo.list_open_alerts_by_unit_month(conn, unit_id, month) + + # 单位名(取第一条) + unit_name = rows[0]["unit_name"] if rows else "(无)" + + items = [] + for r in rows: + items.append({ + "contract_id": r["contract_id"], + "teacher_name": r["teacher_name"], + "alert_level": r["alert_level"], + "days_to_expire": r["days_to_expire"], + "contract_end": r["contract_end"], + }) + + if month: + title = f"{unit_name} {month} 合同到期人员清单及续聘建议" + else: + title = f"{unit_name} 待续聘合同清单" + + body_lines = [ + title, + "", + f"共 {len(items)} 份合同需在到期前办理续聘手续。", + "", + ] + for it in items: + level_zh = {"yellow": "黄色", "orange": "橙色", "red": "红色"}.get( + it["alert_level"], it["alert_level"] + ) + body_lines.append( + f"- {it['teacher_name']}(合同至 {it['contract_end']},{level_zh}预警,距到期 {it['days_to_expire']} 天)" + ) + body_lines.append("") + body_lines.append("请人事秘书尽快通知相关教师备齐续聘材料。") + + payload = { + "schema_version": 1, + "channel": "wecom", + "dry_run": True, + "to": { + "unit_id": unit_id, + "unit_name": unit_name, + "role": "人事秘书", + }, + "title": title, + "body": "\n".join(body_lines), + "items": items, + "generated_at": datetime.now().isoformat(timespec="seconds"), + } + return payload + + +def dispatch( + conn: sqlite3.Connection, + unit_id: str, + month: str = "", +) -> dict: + """派发 dry-run:写 payload 文件 + 写 dispatch_log。""" + payload = build_payload(conn, unit_id, month) + items = payload["items"] + task_ids = [f"T-{it['contract_id']}" for it in items] + + dispatch_dir = get_dispatch_dir() + dispatch_dir.mkdir(parents=True, exist_ok=True) + + month_part = month or "all" + payload_path = dispatch_dir / f"{unit_id}_{month_part}.json" + payload_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + log_id = f"L-{uuid.uuid4().hex[:8]}" + repo.insert_dispatch_log( + conn, log_id, unit_id, month or "all", task_ids, str(payload_path), + status="dry_run", + ) + conn.commit() + + logger.info( + "dispatcher: unit=%s month=%s items=%d payload=%s log=%s", + unit_id, month or "(all)", len(items), payload_path, log_id, + ) + return { + "log_id": log_id, + "unit_id": unit_id, + "unit_name": payload["to"]["unit_name"], + "month": month or "(all)", + "item_count": len(items), + "payload_path": str(payload_path), + "status": "dry_run", + } diff --git a/scripts/service/html_renderer.py b/scripts/service/html_renderer.py new file mode 100644 index 0000000..215e028 --- /dev/null +++ b/scripts/service/html_renderer.py @@ -0,0 +1,114 @@ +"""HTML 报告渲染:Jinja2 模板 → 写 output/.html。 + +模板:scripts/templates/task.html.j2 +占位替换规则: +- {{TITLE}}, {{TASK_ID}}, {{GENERATED_AT}} +- {{TEACHER_NAME}}, {{EMPLOYEE_ID}}, {{UNIT_NAME}}, {{POSITION}}, {{CONTRACT_TYPE}} +- {{CONTRACT_START}}, {{CONTRACT_END}}, {{DAYS_TO_EXPIRE}}, {{ALERT_LEVEL}} +- {{PERFORMANCE_SUMMARY}} +- {{SUGGESTION_CLASS}}, {{SUGGESTION_LABEL}}, {{SUGGESTION_REASON}} +- {{DEADLINE}}, {{CHECKLIST_BODY}} +""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import date, datetime, timedelta +from pathlib import Path + +from db import repository as repo +from util.constants import ALERT_LEVEL_LABELS, SUGGESTION_LABELS +from util.date_util import parse_iso_date +from util.logging_setup import get_skill_logger +from util.runtime_paths import get_output_dir, get_templates_dir + +logger = get_skill_logger() + +SUGGESTION_CLASS_MAP = { + "renew": "renew", + "observe": "observe", + "non_renew": "non-renew", +} + + +def render_task_html(conn: sqlite3.Connection, task_id: str) -> Path: + """渲染指定 task 为 HTML 文件,返回输出路径。 + + 不存在 task → 抛 ValueError。 + """ + task = repo.get_task(conn, task_id) + if task is None: + raise ValueError(f"task_id={task_id!r} 不存在") + + # 关联 alert 的 days_to_expire / level(用于显示) + alert = conn.execute( + "SELECT * FROM alerts WHERE alert_id = ?", (task["alert_id"],) + ).fetchone() if task["alert_id"] else None + + days_to_expire = alert["days_to_expire"] if alert else None + alert_level = alert["alert_level"] if alert else "yellow" + alert_level_label = ALERT_LEVEL_LABELS.get(alert_level, alert_level) + + # 截止日 = 合同到期日 - 30 天(教师备齐材料的最后期限) + contract_end = parse_iso_date(task["contract_end"]) + deadline = (contract_end - timedelta(days=30)).isoformat() + + # 模板上下文 + checklist_items = json.loads(task["materials_checklist"]) if task["materials_checklist"] else [] + checklist_html = "\n".join( + f'
  • {item}
  • ' for item in checklist_items + ) + + suggestion_class = SUGGESTION_CLASS_MAP.get(task["ai_suggestion"], "observe") + suggestion_label = SUGGESTION_LABELS.get(task["ai_suggestion"], task["ai_suggestion"]) + + ctx = { + "TITLE": "续聘准备任务书", + "TASK_ID": task_id, + "GENERATED_AT": task["generated_at"], + "TEACHER_NAME": task["teacher_name"], + "EMPLOYEE_ID": task["employee_id"], + "UNIT_NAME": task["unit_name"], + "POSITION": task["position"], + "CONTRACT_TYPE": task["contract_type"], + "CONTRACT_START": task["contract_start"], + "CONTRACT_END": task["contract_end"], + "DAYS_TO_EXPIRE": days_to_expire if days_to_expire is not None else "—", + "ALERT_LEVEL": alert_level_label, + "PERFORMANCE_SUMMARY": task["performance_summary"], + "SUGGESTION_CLASS": suggestion_class, + "SUGGESTION_LABEL": suggestion_label, + "SUGGESTION_REASON": task["suggestion_reason"], + "DEADLINE": deadline, + "CHECKLIST_BODY": checklist_html, + } + + template_path = get_templates_dir() / "task.html.j2" + if not template_path.exists(): + raise FileNotFoundError(f"模板不存在: {template_path}") + + html = _render_template(template_path, ctx) + + output_dir = get_output_dir() + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / f"{task_id}.html" + output_path.write_text(html, encoding="utf-8") + + logger.info("html_renderer: %s → %s", task_id, output_path) + return output_path + + +def _render_template(template_path: Path, ctx: dict) -> str: + """用 Jinja2 渲染模板;本地无 jinja2 时降级到占位符替换。""" + template_text = template_path.read_text(encoding="utf-8") + + try: + from jinja2 import Template + + return Template(template_text).render(**ctx) + except ImportError: + # 降级:纯 str.replace(最简实现) + for k, v in ctx.items(): + template_text = template_text.replace("{{" + k + "}}", str(v)) + return template_text diff --git a/scripts/service/scanner.py b/scripts/service/scanner.py new file mode 100644 index 0000000..c77bf0c --- /dev/null +++ b/scripts/service/scanner.py @@ -0,0 +1,88 @@ +"""扫描器:扫全校合同 → 分级 → 写 alerts + 生成 renewal_tasks。 + +核心业务入口,cron 每日凌晨 02:00 调用。 +""" + +from __future__ import annotations + +import sqlite3 +from datetime import date +from typing import Tuple + +from db import repository as repo +from service.task_generator import generate_task_for_contract +from util.constants import ALERT_LEVELS, DEFAULT_THRESHOLDS +from util.date_util import classify_alert, days_to, parse_iso_date +from util.logging_setup import get_skill_logger + +logger = get_skill_logger() + + +def scan( + conn: sqlite3.Connection, + thresholds: Tuple[int, int, int] = DEFAULT_THRESHOLDS, + today: date | None = None, + scan_id: str | None = None, +) -> dict: + """扫描全校合同,返回结构化结果。 + + thresholds = (yellow_max, orange_max, red_max),默认 (90, 60, 30)。 + + 返回: + { + "scanned_count": , + "yellow_count": , + "orange_count": , + "red_count": , + "generated_task_count": , + "scan_id": , + "thresholds": [yellow_max, orange_max, red_max], + } + """ + today = today or date.today() + scan_id = scan_id or f"S{datetime_now_compact()}" + + contracts = repo.list_active_contracts(conn) + counts = {f"{lvl}_count": 0 for lvl in ALERT_LEVELS} + generated = 0 + + logger.info("scan: start scan_id=%s thresholds=%s today=%s contracts=%d", + scan_id, thresholds, today.isoformat(), len(contracts)) + + with conn: + for c in contracts: + contract_end = parse_iso_date(c["contract_end"]) + days = days_to(contract_end, today) + level = classify_alert(days, thresholds) + if level is None: + continue + + # 写 alert(alert_id 包含 scan_id 避免同一合同被多次扫描时冲突) + alert_id = f"A-{c['contract_id']}-{scan_id}" + repo.insert_alert(conn, alert_id, c["contract_id"], level, days, scan_id) + counts[f"{level}_count"] += 1 + + # 生成任务书 + task_id = f"T-{c['contract_id']}-{scan_id}" + task = generate_task_for_contract(conn, c, alert_id, task_id) + if task: + generated += 1 + + result = { + "scanned_count": len(contracts), + "yellow_count": counts["yellow_count"], + "orange_count": counts["orange_count"], + "red_count": counts["red_count"], + "generated_task_count": generated, + "scan_id": scan_id, + "thresholds": list(thresholds), + "today": today.isoformat(), + } + logger.info("scan: done %s", result) + return result + + +def datetime_now_compact() -> str: + """生成紧凑时间字符串,用于 alert_id / task_id / scan_id。""" + from datetime import datetime + return datetime.now().strftime("%Y%m%d%H%M%S") diff --git a/scripts/service/task_generator.py b/scripts/service/task_generator.py new file mode 100644 index 0000000..6ded5b5 --- /dev/null +++ b/scripts/service/task_generator.py @@ -0,0 +1,145 @@ +"""任务书生成器:基于规则生成 AI 续聘建议 + 性能摘要 + 材料清单。 + +本地规则版(v1.1.0):不调用 LLM。 +后续可替换为: +- jiangchang_skill_core.llm_manager 调用 LLM +- 或在 generate_task_for_contract 里加 LLM 决策分支 +""" + +from __future__ import annotations + +import sqlite3 + +from db import repository as repo +from util.constants import ( + DEFAULT_MATERIALS, + MORAL_FAIL_THRESHOLD, + PERFORMANCE_FAIL_THRESHOLD, + PERFORMANCE_RENEW_MIN, +) +from util.logging_setup import get_skill_logger + +logger = get_skill_logger() + + +def suggest(perf_records: list[sqlite3.Row]) -> tuple[str, str]: + """基于近 3 年业绩规则生成续聘建议。 + + 返回 (suggestion, reason)。 + + 规则(顺序判断): + 1. 师德评分 < MORAL_FAIL_THRESHOLD(60)→ non_renew + 2. 任意一年考核 < PERFORMANCE_FAIL_THRESHOLD → observe + 3. 全部 >= PERFORMANCE_RENEW_MIN 且逐年上升 → renew + 4. 其他 → observe + """ + if not perf_records: + return ("observe", "近 3 年业绩数据缺失,需人工补录") + + # 取最近一条作为师德评分参考(最新一年) + moral = perf_records[-1]["moral_teaching_score"] + if moral < MORAL_FAIL_THRESHOLD: + return ("non_renew", f"师德师风考评 {moral:.0f} 分,未达标") + + scores = [p["performance_score"] for p in perf_records] + fail_years = sum(1 for s in scores if s < PERFORMANCE_FAIL_THRESHOLD) + if fail_years >= 1: + return ("observe", f"近 3 年考核有 {fail_years} 次不合格(< {PERFORMANCE_FAIL_THRESHOLD} 分),建议复核") + + # 全部 >= 80 且逐年上升(升序排 → 严格递增) + all_high = all(s >= PERFORMANCE_RENEW_MIN for s in scores) + upward = all(scores[i] <= scores[i + 1] for i in range(len(scores) - 1)) + if all_high and upward: + return ("renew", f"近 3 年考核均 ≥ {PERFORMANCE_RENEW_MIN} 分且逐年上升") + + return ("observe", "进入常规续聘评审流程") + + +def summarize(perf_records: list[sqlite3.Row]) -> str: + """生成近 3 年业绩摘要文本(HTML 任务书里 pre-wrap 显示)。""" + if not perf_records: + return "(暂无业绩数据,需补录)" + + lines = [] + for p in perf_records: + lines.append( + f"{p['year']} 年度:综合考核 {p['performance_score']:.0f} 分" + f"(教学 {p['teaching_score']:.0f} / 科研 {p['research_score']:.0f})" + f",师德 {p['moral_teaching_score']:.0f} 分" + ) + return "\n".join(lines) + + +def generate_task_for_contract( + conn: sqlite3.Connection, + contract: sqlite3.Row, + alert_id: str, + task_id: str, +) -> dict | None: + """为单份合同生成 renewal_task。 + + 返回写入库的 task 摘要 dict(不入库时返回 None)。 + """ + perf = repo.list_performance_by_contract(conn, contract["contract_id"]) + suggestion, reason = suggest(perf) + summary = summarize(perf) + + repo.insert_task( + conn, + task_id=task_id, + contract_id=contract["contract_id"], + alert_id=alert_id, + suggestion=suggestion, + reason=reason, + summary=summary, + checklist=list(DEFAULT_MATERIALS), + ) + + logger.info( + "task_generator: %s contract=%s → %s (%s)", + task_id, contract["contract_id"], suggestion, reason, + ) + return { + "task_id": task_id, + "contract_id": contract["contract_id"], + "ai_suggestion": suggestion, + "suggestion_reason": reason, + } + + +def regenerate_task_for_contract_id( + conn: sqlite3.Connection, contract_id: str, task_id: str | None = None +) -> dict: + """单独 generate-renewal-task CLI 入口:基于已有合同 + 最新 alert 生成任务。 + + 如果没有 alert,自动创建 yellow 级占位 alert(合同已临近)。 + """ + contract = repo.get_contract(conn, contract_id) + if contract is None: + raise ValueError(f"contract_id={contract_id!r} 不存在") + + # 取最新 open alert(如果有) + alert = conn.execute( + """ + SELECT * FROM alerts + WHERE contract_id = ? AND status = 'open' + ORDER BY generated_at DESC LIMIT 1 + """, + (contract_id,), + ).fetchone() + + if alert is None: + # 自动建一个 yellow 级 placeholder alert + from util.date_util import days_to, parse_iso_date + days = days_to(parse_iso_date(contract["contract_end"])) + from util.constants import DEFAULT_THRESHOLDS + from util.date_util import classify_alert + + level = classify_alert(days, DEFAULT_THRESHOLDS) or "yellow" + import uuid + alert_id = f"A-{contract_id}-manual-{uuid.uuid4().hex[:6]}" + repo.insert_alert(conn, alert_id, contract_id, level, days, scan_id="manual") + alert = {"alert_id": alert_id, "alert_level": level, "days_to_expire": days} + + task_id = task_id or f"T-{contract_id}-manual" + return generate_task_for_contract(conn, contract, alert["alert_id"], task_id) diff --git a/scripts/templates/task.html.j2 b/scripts/templates/task.html.j2 new file mode 100644 index 0000000..e83c4b9 --- /dev/null +++ b/scripts/templates/task.html.j2 @@ -0,0 +1,118 @@ + + + + + {{TITLE}} + + + +
    +
    +

    {{TITLE}}

    +
    任务编号:{{TASK_ID}} · 生成时间:{{GENERATED_AT}}
    +
    + +
    +
    教职工姓名
    {{TEACHER_NAME}}
    +
    工号
    {{EMPLOYEE_ID}}
    +
    所在单位
    {{UNIT_NAME}}
    +
    聘任岗位
    {{POSITION}}
    +
    合同类型
    {{CONTRACT_TYPE}}
    +
    合同起止
    {{CONTRACT_START}} ~ {{CONTRACT_END}}
    +
    距到期日
    {{DAYS_TO_EXPIRE}} 天
    +
    预警等级
    {{ALERT_LEVEL}}
    +
    + +
    +

    近三年工作业绩摘要

    +
    {{PERFORMANCE_SUMMARY}}
    +
    + +
    +

    AI 续聘建议

    +
    + {{SUGGESTION_LABEL}}
    + {{SUGGESTION_REASON}} +
    +
    + +
    +

    所需材料清单(请于 {{DEADLINE}} 前备齐)

    +
      +{{CHECKLIST_BODY}} +
    +
    + + +
    + + diff --git a/scripts/util/constants.py b/scripts/util/constants.py new file mode 100644 index 0000000..ed8e118 --- /dev/null +++ b/scripts/util/constants.py @@ -0,0 +1,45 @@ +"""技能标识、版本与阈值常量。 + +复制到新 skill 时必须修改: +- SKILL_SLUG 与 SKILL.md metadata.openclaw.slug 保持一致 +- SKILL_VERSION 与 SKILL.md version 保持一致 +""" + +SKILL_SLUG = "alert-contract-expiring-hr" +SKILL_VERSION = "1.1.0" +LOG_LOGGER_NAME = "openclaw.skill.alert_contract_expiring_hr" +PLATFORM_KIT_MIN_VERSION = "1.2.11" + +# 预警阈值(黄/橙/红):距到期日 <= yellow 天 = 黄色预警 +# 顺序:red < orange < yellow +DEFAULT_THRESHOLDS = (90, 60, 30) + +# 预警等级名称(与 thresholds 顺序一一对应) +ALERT_LEVELS = ("yellow", "orange", "red") +ALERT_LEVEL_LABELS = {"yellow": "黄色", "orange": "橙色", "red": "红色"} + +# 续聘建议 +SUGGESTIONS = ("renew", "observe", "non_renew") +SUGGESTION_LABELS = { + "renew": "建议续聘", + "observe": "常规续聘评审", + "non_renew": "不建议续聘", +} + +# 合同状态 +CONTRACT_STATUSES = ("可续聘", "预警中", "已续签", "已驳回", "已过期", "已撤回") + +# 续签材料清单 +DEFAULT_MATERIALS = [ + "近 3 年年度考核表(盖章)", + "师德师风考评记录", + "学历学位证书复印件", + "岗位聘任登记表", + "劳动合同文本(一式三份)", + "个人述职报告(不少于 2000 字)", +] + +# AI 续聘建议规则阈值 +MORAL_FAIL_THRESHOLD = 60 # 师德 < 60 直接 non_renew +PERFORMANCE_FAIL_THRESHOLD = 60 # 单年考核 < 60 算不合格 +PERFORMANCE_RENEW_MIN = 80 # 全部 >= 80 且逐年上升 → renew diff --git a/scripts/util/date_util.py b/scripts/util/date_util.py new file mode 100644 index 0000000..e4ce4a9 --- /dev/null +++ b/scripts/util/date_util.py @@ -0,0 +1,64 @@ +"""日期工具:距到期日天数、月份匹配、ISO 字符串解析。""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import Tuple + + +def parse_iso_date(s: str) -> date: + """解析 'YYYY-MM-DD' ISO 日期字符串。 + + 容错: + - 已经是 date 实例 → 直接返回 + - 含 'T' 的 ISO datetime → 取 date 部分 + """ + if isinstance(s, date): + return s + s = str(s).strip() + if "T" in s: + s = s.split("T", 1)[0] + return datetime.strptime(s, "%Y-%m-%d").date() + + +def days_to(target: date, today: date | None = None) -> int: + """计算距 target 还有多少天(负数 = 已过期)。""" + today = today or date.today() + return (target - today).days + + +def classify_alert(days: int, thresholds: Tuple[int, int, int] = (90, 60, 30)) -> str | None: + """根据距到期天数返回预警等级。 + + thresholds = (yellow_max, orange_max, red_max),须 yellow_max > orange_max > red_max。 + 返回:'yellow' / 'orange' / 'red' / None(不在预警范围)。 + """ + yellow_max, orange_max, red_max = thresholds + if days > yellow_max: + return None + if days <= red_max: + return "red" + if days <= orange_max: + return "orange" + return "yellow" + + +def month_match(date_to_check: date, month_str: str) -> bool: + """判断日期是否落在 month_str('YYYY-MM')月份内。""" + if not month_str: + return True + try: + y, m = month_str.strip().split("-") + return date_to_check.year == int(y) and date_to_check.month == int(m) + except (ValueError, AttributeError): + return False + + +def today_iso() -> str: + """返回今天日期的 ISO 字符串。""" + return date.today().isoformat() + + +def now_iso() -> str: + """返回当前时间的 ISO 字符串。""" + return datetime.now().isoformat(timespec="seconds") diff --git a/scripts/util/logging_setup.py b/scripts/util/logging_setup.py new file mode 100644 index 0000000..a616916 --- /dev/null +++ b/scripts/util/logging_setup.py @@ -0,0 +1,53 @@ +"""logging 配置:降级到 stderr,避免污染 stdout(stdout 留给结构化输出如 JSON)。 + +优先用 jiangchang_skill_core 提供的 logging;本地无该包时降级到 stderr。 +""" + +from __future__ import annotations + +import logging +import sys + +from util.constants import LOG_LOGGER_NAME, SKILL_SLUG, SKILL_VERSION + +_configured = False + + +def setup_skill_logging() -> logging.Logger: + """初始化 skill logger(幂等)。""" + global _configured + logger = logging.getLogger(LOG_LOGGER_NAME) + if _configured: + return logger + + # 优先用宿主提供的 skill logger 配置 + try: + from jiangchang_skill_core.logging_config import setup_skill_logging as _host_setup + + _host_setup(SKILL_SLUG, LOG_LOGGER_NAME) + _configured = True + return logging.getLogger(LOG_LOGGER_NAME) + except ImportError: + pass + + # 本地降级:基础 stderr handler,UTF-8 safe + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter( + logging.Formatter( + fmt=f"%(asctime)s [{SKILL_SLUG}] %(levelname)s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + logger.setLevel(logging.INFO) + logger.addHandler(handler) + logger.propagate = False + _configured = True + logger.info("logging initialized (local fallback) v%s", SKILL_VERSION) + return logger + + +def get_skill_logger() -> logging.Logger: + """获取 skill logger(不会重复 setup)。""" + if not _configured: + return setup_skill_logging() + return logging.getLogger(LOG_LOGGER_NAME) diff --git a/scripts/util/runtime_paths.py b/scripts/util/runtime_paths.py new file mode 100644 index 0000000..db8600a --- /dev/null +++ b/scripts/util/runtime_paths.py @@ -0,0 +1,82 @@ +"""运行时路径解析。 + +本地环境: +- 仓库根 = scripts/main.py 的父目录的父目录 +- data/ 在仓库根下 +- output/ 在仓库根下 +- .env 在仓库根下 + +宿主环境(jiangchang_skill_core 提供)会覆写这些路径到 +{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{slug}/... +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from util.constants import SKILL_SLUG + +# util/runtime_paths.py → scripts/util/ → scripts/ → 仓库根(再上两级) +_THIS_FILE = Path(__file__).resolve() +_SCRIPTS_DIR = _THIS_FILE.parent.parent # scripts/ +_SKILL_ROOT = _SCRIPTS_DIR.parent # alert-contract-expiring-hr/ + + +def get_skill_root() -> Path: + """返回 skill 仓库根目录。""" + # 优先尊重环境变量(宿主设置过) + env_root = os.environ.get("JIANGCHANG_SKILL_ROOT") + if env_root: + return Path(env_root) + return _SKILL_ROOT + + +def get_data_dir() -> Path: + """返回 SQLite DB 与种子数据目录。""" + env_dir = os.environ.get("JIANGCHANG_DATA_DIR") + if env_dir: + return Path(env_dir) + return get_skill_root() / "data" + + +def get_db_path() -> Path: + """返回 SQLite DB 文件路径。""" + env_db = os.environ.get("DB_PATH") + if env_db: + return Path(env_db) + return get_data_dir() / f"{SKILL_SLUG}.db" + + +def get_output_dir() -> Path: + """返回 HTML 报告输出目录。""" + env_out = os.environ.get("HTML_OUTPUT_DIR") + if env_out: + return Path(env_out) + return get_skill_root() / "output" + + +def get_env_path() -> Path: + """返回 .env 路径(仓库根 / 当前工作目录)。""" + return get_skill_root() / ".env" + + +def get_dispatch_dir() -> Path: + """返回派发 payload JSON 输出目录。""" + return get_data_dir() / "dispatch" + + +def get_examples_dir() -> Path: + """返回样例数据目录。""" + return get_skill_root() / "examples" + + +def get_templates_dir() -> Path: + """返回 Jinja2 模板目录。""" + return _SCRIPTS_DIR.parent / "scripts" / "templates" + + +def ensure_dirs() -> None: + """确保所有需要的目录存在。""" + for d in (get_data_dir(), get_output_dir(), get_dispatch_dir()): + d.mkdir(parents=True, exist_ok=True)