v1.1.0: 实现核心业务逻辑(扫描/任务书/派发/状态机)

新增(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/<task_id>.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 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 14:17:43 +08:00
parent e2b45d690e
commit 06c053d7d7
20 changed files with 1796 additions and 82 deletions

View File

@@ -1,6 +1,14 @@
# 合同到期智能预警与续聘辅助 - 配置示例 # 合同到期智能预警与续聘辅助 - 配置示例
# 行头说明「是什么」;行尾说明「怎么填」;进程环境变量 > 用户 .env > 本文件 # 行头说明「是什么」;行尾说明「怎么填」;进程环境变量 > 用户 .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 # 人事系统 API 入口,例如 https://hr.example.edu/api/v1
HR_API_BASE_URL= HR_API_BASE_URL=
@@ -28,5 +36,6 @@ DAILY_SCAN_AT=02:00
# 全校扫描超时(秒),默认 120 # 全校扫描超时(秒),默认 120
SCAN_TIMEOUT_SEC=120 SCAN_TIMEOUT_SEC=120
# 错误码前缀,统一为 ERROR: 起头 # 日志级别
LOG_LEVEL=INFO LOG_LEVEL=INFO

8
.gitignore vendored
View File

@@ -8,3 +8,11 @@ htmlcov/
dist/ dist/
build/ build/
*.egg-info/ *.egg-info/
# 运行时产物(不入库)
data/
output/
*.db
*.db-journal
*.sqlite
*.sqlite3

View File

@@ -2,6 +2,22 @@
本文件对应技能市场 **「更新日志」** Tab。 本文件对应技能市场 **「更新日志」** 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 <cmd>` 9 个命令全部 exit code = 0
## 1.0.1 ## 1.0.1
- 初版:基于匠厂 skill-template 主仓 v1.0.108 派生 - 初版:基于匠厂 skill-template 主仓 v1.0.108 派生

View File

@@ -1,7 +1,7 @@
--- ---
name: 合同到期智能预警与续聘辅助 name: 合同到期智能预警与续聘辅助
description: "每日凌晨扫描全校教职工合同电子台账,对距离到期日 90/60/30 天的合同分级预警,并自动生成《续聘准备任务书》和《本月合同到期人员清单》。" description: "每日凌晨扫描全校教职工合同电子台账,对距离到期日 90/60/30 天的合同分级预警,并自动生成《续聘准备任务书》和《本月合同到期人员清单》。"
version: 1.0.1 version: 1.1.0
author: 深圳匠厂科技有限公司 author: 深圳匠厂科技有限公司
metadata: metadata:
openclaw: openclaw:

View File

@@ -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,可续聘
1 contract_id employee_id teacher_name unit_id unit_name position contract_type contract_start contract_end trial_period_end status
2 C001 E0001 朱华 U01 计算机学院 副教授 聘用合同 2023-08-29 2026-08-13 2024-08-28 已驳回
3 C002 E0002 周超 U02 经管学院 副教授 聘用合同 2023-09-22 2026-08-25 2024-09-21 已驳回
4 C003 E0003 徐磊敏 U03 马克思主义学院 副教授 劳动合同 2023-09-15 2026-08-07 2024-09-14 已驳回
5 C004 E0004 李涛 U04 教务处 助教 聘用合同 2023-09-13 2026-08-15 2024-09-12 已驳回
6 C005 E0005 赵秀英丽 U05 后勤处 讲师 劳动合同 2023-10-12 2026-08-11 2024-10-11 已驳回
7 C006 E0006 陈明 U01 计算机学院 助教 聘用合同 2023-09-14 2026-09-25 2024-09-13 可续聘
8 C007 E0007 胡洋军 U02 经管学院 教授 聘用合同 2023-08-31 2026-09-16 2024-08-30 可续聘
9 C008 E0008 王杰平 U03 马克思主义学院 助教 劳动合同 2023-08-26 2026-09-30 2024-08-25 可续聘
10 C009 E0009 吴艳 U04 教务处 副教授 聘用合同 2023-09-11 2026-09-26 2024-09-10 可续聘
11 C010 E0010 陈刚磊 U05 后勤处 副教授 劳动合同 2023-09-19 2026-10-08 2024-09-18 可续聘
12 C011 E0011 徐勇 U01 计算机学院 教授 劳动合同 2023-08-27 2026-09-23 2024-08-26 可续聘
13 C012 E0012 何杰敏 U02 经管学院 教授 劳动合同 2023-10-08 2026-10-02 2024-10-07 可续聘
14 C013 E0013 何艳娜 U03 马克思主义学院 助教 劳动合同 2023-09-18 2026-09-18 2024-09-17 可续聘
15 C014 E0014 郭涛平 U04 教务处 讲师 聘用合同 2023-09-06 2026-10-07 2024-09-05 可续聘
16 C015 E0015 胡勇静 U05 后勤处 助教 劳动合同 2023-08-22 2026-10-02 2024-08-21 可续聘
17 C016 E0016 马娜强 U01 计算机学院 助教 聘用合同 2023-08-23 2026-10-20 2024-08-22 可续聘
18 C017 E0017 高秀英艳 U02 经管学院 副教授 劳动合同 2023-08-17 2026-10-20 2024-08-16 可续聘
19 C018 E0018 赵芳 U03 马克思主义学院 教授 聘用合同 2023-10-13 2026-10-23 2024-10-12 可续聘
20 C019 E0019 林勇娜 U04 教务处 讲师 劳动合同 2023-09-10 2026-10-30 2024-09-09 可续聘
21 C020 E0020 杨娜杰 U05 后勤处 助教 聘用合同 2023-10-11 2026-11-11 2024-10-10 可续聘
22 C021 E0021 吴桂英 U01 计算机学院 讲师 劳动合同 2023-10-08 2026-11-03 2024-10-07 可续聘
23 C022 E0022 林娟 U02 经管学院 副教授 聘用合同 2023-08-25 2026-11-08 2024-08-24 可续聘
24 C023 E0023 朱娟 U03 马克思主义学院 教授 劳动合同 2023-09-11 2026-10-21 2024-09-10 可续聘
25 C024 E0024 林杰超 U04 教务处 教授 劳动合同 2023-10-06 2026-11-09 2024-10-05 可续聘
26 C025 E0025 黄静 U05 后勤处 副教授 聘用合同 2023-10-07 2026-11-02 2024-10-06 可续聘
27 C026 E0026 李杰 U01 计算机学院 副教授 劳动合同 2023-09-27 2026-11-29 2024-09-26 可续聘
28 C027 E0027 何磊勇 U02 经管学院 教授 聘用合同 2023-09-18 2026-11-26 2024-09-17 可续聘
29 C028 E0028 吴静涛 U03 马克思主义学院 副教授 劳动合同 2023-09-07 2026-12-11 2024-09-06 可续聘
30 C029 E0029 郭磊娟 U04 教务处 副教授 劳动合同 2023-10-03 2026-12-08 2024-10-02 可续聘
31 C030 E0030 杨明强 U05 后勤处 助教 聘用合同 2023-09-11 2026-11-26 2024-09-10 可续聘
32 C031 E0031 王杰涛 U01 计算机学院 讲师 劳动合同 2023-08-18 2026-12-05 2024-08-17 可续聘
33 C032 E0032 朱涛华 U02 经管学院 教授 劳动合同 2023-08-31 2026-11-21 2024-08-30 可续聘
34 C033 E0033 罗超 U03 马克思主义学院 副教授 劳动合同 2023-09-03 2026-12-11 2024-09-02 可续聘
35 C034 E0034 杨强 U04 教务处 教授 劳动合同 2023-09-28 2026-11-16 2024-09-27 可续聘
36 C035 E0035 高磊 U05 后勤处 副教授 聘用合同 2023-10-01 2026-11-27 2024-09-30 可续聘
37 C036 E0036 黄洋平 U01 计算机学院 助教 劳动合同 2023-08-24 2027-02-23 2024-08-23 已续签
38 C037 E0037 徐明芳 U02 经管学院 讲师 聘用合同 2023-09-26 2026-12-19 2024-09-25 已续签
39 C038 E0038 赵伟超 U03 马克思主义学院 助教 聘用合同 2023-10-11 2027-03-01 2024-10-10 已续签
40 C039 E0039 罗伟 U04 教务处 讲师 聘用合同 2023-08-30 2027-04-02 2024-08-29 可续聘
41 C040 E0040 郭明霞 U05 后勤处 教授 劳动合同 2023-09-08 2027-02-02 2024-09-07 可续聘
42 C041 E0041 周芳霞 U01 计算机学院 助教 劳动合同 2023-10-09 2027-05-13 2024-10-08 可续聘
43 C042 E0042 黄丽 U02 经管学院 副教授 劳动合同 2023-10-05 2027-07-16 2024-10-04 可续聘
44 C043 E0043 王艳刚 U03 马克思主义学院 教授 劳动合同 2023-10-14 2027-08-28 2024-10-13 可续聘
45 C044 E0044 朱杰桂英 U04 教务处 副教授 劳动合同 2023-08-24 2027-04-16 2024-08-23 可续聘
46 C045 E0045 张勇明 U05 后勤处 教授 劳动合同 2023-09-20 2027-08-06 2024-09-19 可续聘
47 C046 E0046 罗秀英 U01 计算机学院 讲师 聘用合同 2023-09-30 2027-07-26 2024-09-29 可续聘
48 C047 E0047 王伟 U02 经管学院 副教授 劳动合同 2023-09-03 2026-12-22 2024-09-02 可续聘
49 C048 E0048 刘鹏娜 U03 马克思主义学院 讲师 聘用合同 2023-09-08 2027-02-25 2024-09-07 可续聘
50 C049 E0049 黄娜鹏 U04 教务处 副教授 聘用合同 2023-10-13 2027-07-16 2024-10-12 可续聘
51 C050 E0050 马涛强 U05 后勤处 副教授 劳动合同 2023-09-02 2027-06-21 2024-09-01 可续聘

View File

@@ -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
1 record_id contract_id year performance_score moral_teaching_score research_score teaching_score
2 R0001 C001 2023 89 93 93 84
3 R0002 C001 2024 87 93 84 86
4 R0003 C001 2025 77 93 71 79
5 R0004 C002 2023 75 93 84 70
6 R0005 C002 2024 80 93 90 80
7 R0006 C002 2025 95 93 89 98
8 R0007 C003 2023 80 86 77 81
9 R0008 C003 2024 76 86 80 78
10 R0009 C003 2025 93 86 87 88
11 R0010 C004 2023 88 78 90 90
12 R0011 C004 2024 90 78 91 88
13 R0012 C004 2025 77 78 77 77
14 R0013 C005 2023 92 97 91 88
15 R0014 C005 2024 82 97 76 86
16 R0015 C005 2025 76 97 83 81
17 R0016 C006 2023 92 99 98 91
18 R0017 C006 2024 93 99 91 88
19 R0018 C006 2025 81 99 80 85
20 R0019 C007 2023 79 93 69 74
21 R0020 C007 2024 76 93 70 75
22 R0021 C007 2025 91 93 96 88
23 R0022 C008 2023 75 91 82 78
24 R0023 C008 2024 75 91 74 80
25 R0024 C008 2025 95 91 98 94
26 R0025 C009 2023 78 87 84 82
27 R0026 C009 2024 86 87 96 86
28 R0027 C009 2025 95 87 100 94
29 R0028 C010 2023 84 80 78 80
30 R0029 C010 2024 79 80 81 76
31 R0030 C010 2025 93 80 99 95
32 R0031 C011 2023 83 88 81 78
33 R0032 C011 2024 78 88 76 75
34 R0033 C011 2025 84 88 93 79
35 R0034 C012 2023 91 88 83 93
36 R0035 C012 2024 82 88 77 81
37 R0036 C012 2025 77 88 79 75
38 R0037 C013 2023 77 98 82 77
39 R0038 C013 2024 76 98 85 73
40 R0039 C013 2025 85 98 94 80
41 R0040 C014 2023 91 87 96 92
42 R0041 C014 2024 85 87 90 90
43 R0042 C014 2025 93 87 84 91
44 R0043 C015 2023 86 88 84 86
45 R0044 C015 2024 79 88 71 79
46 R0045 C015 2025 91 88 83 95
47 R0046 C016 2023 94 78 94 93
48 R0047 C016 2024 90 78 88 85
49 R0048 C016 2025 55 78 55 51
50 R0049 C017 2023 93 78 96 92
51 R0050 C017 2024 79 78 86 78
52 R0051 C017 2025 85 78 76 85
53 R0052 C018 2023 80 76 77 79
54 R0053 C018 2024 84 76 77 83
55 R0054 C018 2025 95 76 88 100
56 R0055 C019 2023 85 81 90 89
57 R0056 C019 2024 86 81 79 84
58 R0057 C019 2025 94 81 93 95
59 R0058 C020 2023 82 76 82 78
60 R0059 C020 2024 90 76 92 89
61 R0060 C020 2025 75 76 84 74
62 R0061 C021 2023 83 90 77 79
63 R0062 C021 2024 79 90 85 81
64 R0063 C021 2025 77 90 74 79
65 R0064 C022 2023 93 76 97 96
66 R0065 C022 2024 81 76 85 86
67 R0066 C022 2025 91 76 86 94
68 R0067 C023 2023 85 99 82 81
69 R0068 C023 2024 86 99 89 82
70 R0069 C023 2025 83 99 77 86
71 R0070 C024 2023 77 95 74 77
72 R0071 C024 2024 78 95 72 77
73 R0072 C024 2025 89 95 88 94
74 R0073 C025 2023 75 97 68 80
75 R0074 C025 2024 82 97 78 87
76 R0075 C025 2025 90 97 98 94
77 R0076 C026 2023 79 75 75 74
78 R0077 C026 2024 93 75 84 91
79 R0078 C026 2025 90 75 80 87
80 R0079 C027 2023 80 76 75 85
81 R0080 C027 2024 94 76 98 95
82 R0081 C027 2025 77 76 74 77
83 R0082 C028 2023 83 76 92 81
84 R0083 C028 2024 79 76 74 83
85 R0084 C028 2025 81 76 86 77
86 R0085 C029 2023 90 84 80 95
87 R0086 C029 2024 81 84 76 77
88 R0087 C029 2025 75 84 65 75
89 R0088 C030 2023 92 79 90 95
90 R0089 C030 2024 84 79 75 86
91 R0090 C030 2025 94 79 96 95
92 R0091 C031 2023 90 91 93 93
93 R0092 C031 2024 79 91 83 84
94 R0093 C031 2025 84 91 85 87
95 R0094 C032 2023 85 88 78 82
96 R0095 C032 2024 92 88 90 95
97 R0096 C032 2025 91 88 99 91
98 R0097 C033 2023 88 98 90 88
99 R0098 C033 2024 83 98 90 87
100 R0099 C033 2025 86 98 85 81
101 R0100 C034 2023 80 97 83 84
102 R0101 C034 2024 94 97 90 89
103 R0102 C034 2025 95 97 86 96
104 R0103 C035 2023 82 76 90 79
105 R0104 C035 2024 90 76 86 95
106 R0105 C035 2025 77 76 73 73
107 R0106 C036 2023 83 91 83 87
108 R0107 C036 2024 95 91 100 98
109 R0108 C036 2025 76 91 86 74
110 R0109 C037 2023 79 98 78 84
111 R0110 C037 2024 94 98 93 98
112 R0111 C037 2025 75 98 67 74
113 R0112 C038 2023 95 89 86 96
114 R0113 C038 2024 87 89 87 87
115 R0114 C038 2025 92 89 88 92
116 R0115 C039 2023 90 91 96 91
117 R0116 C039 2024 86 91 79 89
118 R0117 C039 2025 81 91 84 82
119 R0118 C040 2023 76 98 84 81
120 R0119 C040 2024 82 98 72 80
121 R0120 C040 2025 91 98 84 92
122 R0121 C041 2023 84 99 76 81
123 R0122 C041 2024 85 99 78 83
124 R0123 C041 2025 81 99 79 82
125 R0124 C042 2023 82 99 86 78
126 R0125 C042 2024 80 99 70 79
127 R0126 C042 2025 79 99 79 78
128 R0127 C043 2023 94 82 98 99
129 R0128 C043 2024 77 82 71 78
130 R0129 C043 2025 91 82 97 93
131 R0130 C044 2023 75 82 79 73
132 R0131 C044 2024 78 82 73 83
133 R0132 C044 2025 88 82 79 83
134 R0133 C045 2023 93 94 100 97
135 R0134 C045 2024 76 94 77 73
136 R0135 C045 2025 79 94 82 80
137 R0136 C046 2023 81 86 71 86
138 R0137 C046 2024 82 86 82 82
139 R0138 C046 2025 78 86 82 76
140 R0139 C047 2023 79 83 73 78
141 R0140 C047 2024 89 83 97 86
142 R0141 C047 2025 92 83 90 93
143 R0142 C048 2023 92 81 87 91
144 R0143 C048 2024 84 81 89 83
145 R0144 C048 2025 85 81 78 81
146 R0145 C049 2023 81 86 80 80
147 R0146 C049 2024 77 86 80 82
148 R0147 C049 2025 87 86 93 83
149 R0148 C050 2023 76 96 82 75
150 R0149 C050 2024 94 96 93 92
151 R0150 C050 2025 93 96 100 93

274
scripts/cli/app.py Normal file
View File

@@ -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)

134
scripts/db/connection.py Normal file
View File

@@ -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")

195
scripts/db/repository.py Normal file
View File

@@ -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()

90
scripts/db/seed_data.py Normal file
View File

@@ -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))

View File

@@ -1,91 +1,45 @@
#!/usr/bin/env python3 #!/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 import sys
from pathlib import Path
# 占位:开发者后续接入 jiangchang_skill_core # UTF-8 输出兼容(Windows)
# from jiangchang_skill_core import ... 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(): def _apply_host_runtime() -> None:
"""运行检查(占位)。""" """宿主共享 runtime 集成(本地无 jiangchang_skill_core 时跳过)。"""
print(f"alert-contract-expiring-hr health check (placeholder)") try:
print(f"base_dir: {BASE_DIR}") from jiangchang_skill_core.runtime_env import apply_cli_local_defaults
return 0
apply_cli_local_defaults()
except ImportError:
pass # 本地环境没有宿主 runtime,跳过
def version(): _apply_host_runtime()
"""查看版本(占位)。"""
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 <command>")
print("Commands:", ", ".join(COMMANDS.keys()))
return 1
return COMMANDS[argv[0]](*argv[1:])
from cli.app import main
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) raise SystemExit(main())

View File

@@ -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",
}

View File

@@ -0,0 +1,114 @@
"""HTML 报告渲染:Jinja2 模板 → 写 output/<task_id>.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' <li><span class="box"></span>{item}</li>' 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

View File

@@ -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": <int>,
"yellow_count": <int>,
"orange_count": <int>,
"red_count": <int>,
"generated_task_count": <int>,
"scan_id": <str>,
"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")

View File

@@ -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)

View File

@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>{{TITLE}}</title>
<style>
/* 数据管理 iframe 预览布局基线 */
html, body {
margin: 0;
padding: 16px;
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: #1f2329;
background: #fafbfc;
line-height: 1.6;
}
.report { max-width: 100%; }
.header {
padding: 20px;
background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%);
color: #fff;
border-radius: 8px;
margin-bottom: 16px;
}
.header h1 { margin: 0 0 8px; font-size: 22px; }
.header .meta { color: #dbeafe; font-size: 13px; }
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.info-card {
padding: 12px 16px;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 8px;
}
.info-card .label { color: #8c959f; font-size: 12px; }
.info-card .value { font-size: 16px; font-weight: 600; margin-top: 4px; word-break: break-word; }
.section {
padding: 16px 20px;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 8px;
margin-bottom: 16px;
}
.section h2 { margin: 0 0 12px; font-size: 16px; color: #1e3a8a; }
.summary-text { white-space: pre-wrap; color: #4e5969; }
.ai-suggestion {
padding: 14px 18px;
border-left: 4px solid #3b82f6;
background: #eff6ff;
border-radius: 4px;
color: #1e40af;
}
.ai-suggestion.renew { border-left-color: #059669; background: #ecfdf5; color: #065f46; }
.ai-suggestion.observe { border-left-color: #d97706; background: #fffbeb; color: #92400e; }
.ai-suggestion.non-renew { border-left-color: #c53030; background: #fef2f2; color: #991b1b; }
.checklist { list-style: none; padding: 0; margin: 0; }
.checklist li {
padding: 8px 0;
border-bottom: 1px solid #f0f1f3;
display: flex;
align-items: center;
gap: 8px;
}
.checklist li:last-child { border-bottom: 0; }
.checklist .box {
width: 16px; height: 16px;
border: 1px solid #cbd5e1;
border-radius: 3px;
flex-shrink: 0;
}
.footer { margin-top: 16px; color: #8c959f; font-size: 12px; text-align: right; }
</style>
</head>
<body>
<div class="report">
<div class="header">
<h1>{{TITLE}}</h1>
<div class="meta">任务编号:{{TASK_ID}} · 生成时间:{{GENERATED_AT}}</div>
</div>
<div class="info-grid">
<div class="info-card"><div class="label">教职工姓名</div><div class="value">{{TEACHER_NAME}}</div></div>
<div class="info-card"><div class="label">工号</div><div class="value">{{EMPLOYEE_ID}}</div></div>
<div class="info-card"><div class="label">所在单位</div><div class="value">{{UNIT_NAME}}</div></div>
<div class="info-card"><div class="label">聘任岗位</div><div class="value">{{POSITION}}</div></div>
<div class="info-card"><div class="label">合同类型</div><div class="value">{{CONTRACT_TYPE}}</div></div>
<div class="info-card"><div class="label">合同起止</div><div class="value">{{CONTRACT_START}} ~ {{CONTRACT_END}}</div></div>
<div class="info-card"><div class="label">距到期日</div><div class="value">{{DAYS_TO_EXPIRE}} 天</div></div>
<div class="info-card"><div class="label">预警等级</div><div class="value">{{ALERT_LEVEL}}</div></div>
</div>
<div class="section">
<h2>近三年工作业绩摘要</h2>
<div class="summary-text">{{PERFORMANCE_SUMMARY}}</div>
</div>
<div class="section">
<h2>AI 续聘建议</h2>
<div class="ai-suggestion {{SUGGESTION_CLASS}}">
<strong>{{SUGGESTION_LABEL}}</strong><br />
{{SUGGESTION_REASON}}
</div>
</div>
<div class="section">
<h2>所需材料清单(请于 {{DEADLINE}} 前备齐)</h2>
<ul class="checklist">
{{CHECKLIST_BODY}}
</ul>
</div>
<div class="footer">本任务书由「合同到期智能预警与续聘辅助」技能自动生成 · 最终续聘决定权归人事处</div>
</div>
</body>
</html>

45
scripts/util/constants.py Normal file
View File

@@ -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

64
scripts/util/date_util.py Normal file
View File

@@ -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")

View File

@@ -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)

View File

@@ -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)