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:
11
.env.example
11
.env.example
@@ -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
8
.gitignore
vendored
@@ -8,3 +8,11 @@ htmlcov/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
|
||||||
|
# 运行时产物(不入库)
|
||||||
|
data/
|
||||||
|
output/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|||||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -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 派生
|
||||||
|
|||||||
2
SKILL.md
2
SKILL.md
@@ -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:
|
||||||
|
|||||||
51
examples/contracts_sample.csv
Normal file
51
examples/contracts_sample.csv
Normal 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,可续聘
|
||||||
|
151
examples/performance_sample.csv
Normal file
151
examples/performance_sample.csv
Normal 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
|
||||||
|
274
scripts/cli/app.py
Normal file
274
scripts/cli/app.py
Normal 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
134
scripts/db/connection.py
Normal 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
195
scripts/db/repository.py
Normal 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
90
scripts/db/seed_data.py
Normal 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))
|
||||||
112
scripts/main.py
112
scripts/main.py
@@ -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())
|
||||||
|
|||||||
123
scripts/service/dispatcher.py
Normal file
123
scripts/service/dispatcher.py
Normal 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",
|
||||||
|
}
|
||||||
114
scripts/service/html_renderer.py
Normal file
114
scripts/service/html_renderer.py
Normal 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
|
||||||
88
scripts/service/scanner.py
Normal file
88
scripts/service/scanner.py
Normal 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")
|
||||||
145
scripts/service/task_generator.py
Normal file
145
scripts/service/task_generator.py
Normal 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)
|
||||||
118
scripts/templates/task.html.j2
Normal file
118
scripts/templates/task.html.j2
Normal 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
45
scripts/util/constants.py
Normal 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
64
scripts/util/date_util.py
Normal 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")
|
||||||
53
scripts/util/logging_setup.py
Normal file
53
scripts/util/logging_setup.py
Normal 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)
|
||||||
82
scripts/util/runtime_paths.py
Normal file
82
scripts/util/runtime_paths.py
Normal 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)
|
||||||
Reference in New Issue
Block a user