v1.0.1: 合同到期智能预警与续聘辅助 - 初版骨架

基于匠厂 skill-template 主仓 v1.0.108 派生的业务技能。

核心业务:
- 每日凌晨定时扫描全校教职工合同电子台账
- 距到期日 90 / 60 / 30 天分级预警(黄 / 橙 / 红)
- 自动生成个人版《续聘准备任务书》与单位版《本月到期人员清单》
- 30 天红色预警自动抄送分管校领导

输出形态:
- 个人版 HTML 任务书 + 单位版 PDF 清单 + 三级预警通知

预期提效:因合同逾期未续签导致的劳动纠纷降为 0;人事处年终集中突击签合同的工作量减少 60%。

业务行生命周期:可续聘 → 预警中(黄 / 橙 / 红)→ 已续签 / 已驳回 / 已过期

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:19:11 +08:00
commit e2b45d690e
21 changed files with 1502 additions and 0 deletions

32
.env.example Normal file
View File

@@ -0,0 +1,32 @@
# 合同到期智能预警与续聘辅助 - 配置示例
# 行头说明「是什么」;行尾说明「怎么填」;进程环境变量 > 用户 .env > 本文件
# 人事系统 API 入口,例如 https://hr.example.edu/api/v1
HR_API_BASE_URL=
# 人事系统 API 调用凭证(Bearer Token),由人事处统一发放
HR_API_TOKEN=
# 党委教师工作部 API 入口,例如 https://teacher-affairs.example.edu/api/v1
TEACHER_AFFAIRS_API_BASE_URL=
# 党委教师工作部 API 调用凭证
TEACHER_AFFAIRS_API_TOKEN=
# 企业微信 API 入口,用于推送个人版任务书和单位版清单
WECOM_API_BASE_URL=https://qyapi.weixin.qq.com/cgi-bin
# 企业微信 API 调用凭证
WECOM_API_TOKEN=
# 扫描阈值(天),距到期日小于等于此值触发对应等级预警;逗号分隔黄/橙/红
ALERT_THRESHOLDS=90,60,30
# 每日扫描时间(HH:MM,24h 制),cron 启用时使用
DAILY_SCAN_AT=02:00
# 全校扫描超时(秒),默认 120
SCAN_TIMEOUT_SEC=120
# 错误码前缀,统一为 ERROR: 起头
LOG_LEVEL=INFO

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
__pycache__/
*.py[cod]
.env
*.env.local
.pytest_cache/
.coverage
htmlcov/
dist/
build/
*.egg-info/

5
.openclaw-skill-template Normal file
View File

@@ -0,0 +1,5 @@
# 此文件仅存在于 skill-template 源仓库。
# 用 tools/scaffold_skill 创建新技能时,脚本会从目标目录删除本文件。
# 若业务技能仓库中仍存在本文件,说明未正确 scaffold,tests 会失败。
template_slug: your-skill-slug
template_version: "1.0.32"

12
CHANGELOG.md Normal file
View File

@@ -0,0 +1,12 @@
# Changelog
本文件对应技能市场 **「更新日志」** Tab。
## 1.0.1
- 初版:基于匠厂 skill-template 主仓 v1.0.108 派生
- 实现核心业务:合同到期智能预警与续聘辅助(每日定时扫描 + 三级预警 + 个人版任务书 + 单位版清单 + 自动抄送)
- 输出形态:合同台账表状态更新 + 预警记录表 + 个人版 HTML 任务书 + 单位版 PDF 清单 + 派发日志
- 预期提效:因合同逾期未续签导致的劳动纠纷降为 0;人事处年终集中突击签合同的工作量减少 60%
- 业务行生命周期:可预警 → 预警中 → 已续签 / 已驳回 / 已过期
- 占位说明:本版仅完成骨架与文档,业务实现细节(`scripts/` 下 Python 模块、人事系统 API 对接、定时调度)由开发者后续按 `development/REQUIREMENTS.md` 补全

31
DEMO.md Normal file
View File

@@ -0,0 +1,31 @@
---
demo_video_url: ""
---
# 演示说明
> 本技能当前未提供演示视频。下方用图文形式描述完整扫描流程与结果样例。
## 演示看点
1. **定时任务 → 扫描即将到期合同**:每日凌晨 02:00 自动执行(也可手动触发)
2. **数据管理 → 合同台账 → 预警记录**:30 秒~2 分钟内完成全校合同扫描
3. **结果样例**:
- 预警记录表新增 5~20 条预警(含黄 / 橙 / 红等级)
- 教师本人收到企业微信个人版《续聘准备任务书》通知
- 二级单位人事秘书收到单位版《本月到期清单》通知
- 30 天红色预警自动抄送分管校领导
- 合同台账表中相应行进入「预警中(红色)」状态
## 没有视频时
可参考下方截图位(待补):
- 截图位 1:合同台账表(预警中状态)
- 截图位 2:预警记录表(红 / 橙 / 黄三色)
- 截图位 3:个人版《续聘准备任务书》HTML 报告
- 截图位 4:单位版《本月到期清单》通知
## 何时补视频
v1.1.0 起,按 `development/TUTORIAL_SURFACES.md` §3 录制 1~2 分钟三入口演示视频,覆盖定时触发 → 教师收到任务书 → 单位收到清单 → 标记续签的全流程。

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 深圳匠厂科技有限公司
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

81
README.md Normal file
View File

@@ -0,0 +1,81 @@
---
description: "每日凌晨扫描全校教职工合同电子台账,对距离到期日 90/60/30 天的合同分级预警,自动生成个人版续聘任务书和单位版本月到期清单。"
---
# 合同到期智能预警与续聘辅助
每日凌晨自动扫描全校教职工合同电子台账,对距离到期日 90 天 / 60 天 / 30 天的合同分别给黄 / 橙 / 红三级预警,自动生成《续聘准备任务书》推给教师本人,把《本月合同到期人员清单及续聘建议》推给二级单位人事秘书,把劳动纠纷和年终突击签合同彻底挡在门外。
---
## 这个技能能帮你做什么
- **每日凌晨自动扫描**:无需人工介入,按合同到期日距今 90/60/30 天分别给出黄/橙/红三级预警
- **自动预填续聘考核表**:根据该教师近 3 年年度考核结果和师德师风考评,自动生成「近三年工作业绩摘要」
- **AI 建议续聘意见**:依据考核结果(优秀/称职/基本称职/不称职),给出续聘 / 续聘观察 / 不续聘的建议,并附理由(如「连续两年考核排名后 10%」)
- **双轨派发**:个人版《续聘准备任务书》发给教师本人;单位版《本月合同到期人员清单》发给二级单位人事秘书
- **红头预警自动抄送**:30 天红色紧急提醒自动抄送分管校领导
---
## 适合什么场景
- 人事处合同管理员想从「年底集中突击签合同」转向「全年前置预警」
- 二级单位人事秘书每月初就想知道本月谁需要办续聘、还差什么材料
- 教师本人提前 90 天就知道合同什么时候到期、需要准备什么材料
- 分管校领导 30 天内必须知道谁有合同逾期风险
---
## 使用前需要准备什么
- 合同电子台账数据已与人事系统同步(合同类型、起止日期、聘任岗位、试用期)
- 该教师近 3 年年度考核结果可由党委教师工作部系统接口获取
- 师德师风考评记录可由党委教师工作部系统接口获取
- 二级单位人事秘书通讯录已登记到本技能 `.env`
---
## 怎么使用
本技能支持**数据管理**(合同台账表上点工具栏按钮)、**对话任务**(自然语言让 Agent 完成)、**定时任务**(每日凌晨 02:00 自动扫描)。三种入口触发同一套业务能力。
**逐步操作与排障,请查看技能市场「使用教程」Tab。**
---
## 执行后你会得到什么
- **个人版《续聘准备任务书》**:发给教师本人,含合同基本信息、近三年业绩摘要、续聘建议、所需材料清单、截止时间
- **单位版《本月合同到期人员清单及续聘建议》**:发给二级单位人事秘书,含本月全部到期 / 即将到期人员名单、AI 续聘建议、任务派发状态
- **预警提醒**:90 天黄色 / 60 天橙色 / 30 天红色,三档分级
- **任务派发日志**:每一条预警的派发状态、接收人、是否已读
---
## 注意事项
- 本技能**不替代人事处终审**,续聘 / 不续聘的最终决定权仍由人事处与分管校领导行使
- 涉及劳动合同解除 / 不续聘的处分性决定仍须走线下流程,技能仅给出 AI 建议
- 考核结果、师德师风数据延迟同步可能导致预警等级失真,建议每日扫描完成后人工核对一次
- 30 天红色预警触发后,系统将自动抄送分管校领导,请勿手动屏蔽通知
---
## 常见问题
### 这个技能适合我吗?
> 如果你的工作包含「管理全校教职工劳动合同 + 每月办理续签」,本技能可以接管日常预警与材料预填。
### 教师本人能看到 AI 建议的续聘意见吗?
> 个人版《续聘准备任务书》默认**不展示 AI 续聘建议**,仅展示业绩摘要与所需材料;单位版《清单》才展示 AI 建议,避免给教师造成预判压力。
### 数据来源(考核、师德)延迟怎么办?
> 技能每日凌晨扫描时若发现考核数据缺失,会将该合同标记为「数据待补」并跳过预警;考核数据补齐后会自动重新触发。
### 试用期合同也在扫描范围内吗?
> 是。试用期合同同样按到期日距今 90/60/30 天分级预警;试用期考核不通过的处理建议由人事处另行判定。

20
SECURITY.md Normal file
View File

@@ -0,0 +1,20 @@
# 安全漏洞披露
本仓库是匠厂技能开发模板。请把**未公开的安全漏洞**发给深圳匠厂科技有限公司,不要在公开 Issue、PR 或讨论区里贴完整复现步骤、PoC 或利用细节。
## 怎么报告
请通过匠厂官网联系渠道提交:[https://www.jc2009.com/](https://www.jc2009.com/)
建议在联系时注明:
- 标题或开头带 `[Security]`
- 影响的仓库 / 版本(例如 `skill-template`、tag `v1.0.x`)
- 问题类型、影响范围、复现步骤
- 若已有修复思路,可一并说明
我们会确认收悉,并在有结论后告知是否修复、是否公开披露。
## 不在本页范围
本页只说明**如何向我们报告漏洞**。技能运行时的权限、RPA 合规、加密发布等产品安全设计,见 `development/` 与 `references/`,不要写进本文件。

131
SKILL.md Normal file
View File

@@ -0,0 +1,131 @@
---
name: 合同到期智能预警与续聘辅助
description: "每日凌晨扫描全校教职工合同电子台账,对距离到期日 90/60/30 天的合同分级预警,并自动生成《续聘准备任务书》和《本月合同到期人员清单》。"
version: 1.0.1
author: 深圳匠厂科技有限公司
metadata:
openclaw:
slug: alert-contract-expiring-hr
platform_kit_min_version: "1.2.11"
emoji: "📋"
category: "人事"
developer_ids:
- 12345 # 占位,请换成你的匠厂用户 ID
- 12468
allowed-tools:
- bash
---
# 合同到期智能预警与续聘辅助(alert-contract-expiring-hr)
这是基于匠厂 skill-template 主仓 v1.0.108 派生的业务技能。覆盖需求规格书第 4.2 节「🟡 人事处 Skill 002 —— 合同到期"智能预警与续聘辅助"」。
## Agent / 宿主编排(复制为业务技能后必改)
### AI + RPA 定位
- **Agent 是入口与编排者**:理解用户意图("扫描本月即将到期的合同"、"给张三生成续聘任务书")、补齐参数、判断风险、选择 Action,并用业务语言解释结果。
- **本技能不涉及浏览器 / 桌面 / 手机 RPA**:合同台账读取走人事系统官方 API,考核数据走党委教师工作部 API。
- 开发者与 AI 编程工具先读 `development/AI_RPA.md`。
### 架构铁律(先读)
- **宿主产品入口平级**:对话任务 / 数据管理 / 定时任务是同一技能能力的不同触发场景,**同等重要**;本技能**重点挂定时任务入口**(每日凌晨扫描)。
- **单业务内核、多入口**:数据管理 / 定时任务 / Agent / 技能详情 / 直接 CLI 必须复用同一 domain service;禁止按入口复制业务逻辑。
- **业务行生命周期**:本技能对合同台账表的每一份合同有"可预警 / 预警中 / 已续聘 / 已驳回"角色约束,遵守 `references/WORK_ITEM_STATUS.md`。
- **HTML 整页报告**:本技能输出个人版《续聘准备任务书》走 `render-html`;单位版清单走 PDF 导出。
### 如何触发(能力类型)
| 你要做什么 | Agent 怎么做 | 进任务中心? |
|------------|--------------|--------------|
| 健康检查 / 短查询 | OpenClaw bash/exec + 共享 Python 跑 CLI | 否 |
| 已声明 sync 的 Action | `run_skill_action` 或等效短 CLI | 否 |
| 长任务(每日凌晨扫描全校 1000+ 份合同) | **必须** `run_skill_action` | 是(仅因 async) |
### 用户意图路由表(业务技能复制后必改)
| 用户意图(本技能自填) | Agent 怎么做 | 进任务中心? |
|------------------------|--------------|--------------|
| 检查运行环境 | 短 CLI:`python {baseDir}/scripts/main.py health` | 否 |
| 查看版本 | 短 CLI:`python {baseDir}/scripts/main.py version` | 否 |
| 查看配置位置 | 短 CLI:`python {baseDir}/scripts/main.py config-path` | 否 |
| 每日凌晨定时扫描合同到期 | `run_skill_action` → `scan-expiring-contracts`(cron / agent) | 是(async) |
| 给某位教师生成续聘任务书 | `run_skill_action` → `generate-renewal-task`(输入:`contract_id`) | 否(sync) |
| 下发单位版续聘清单 | `run_skill_action` → `dispatch-task-notice`(输入:`unit_id` 或 `month`) | 否(sync) |
| 标记合同为已续签 | `run_skill_action` → `mark-contract-renewed`(输入:`contract_id` + 续签材料 ID) | 否(sync) |
| 在浏览器中打开续聘任务书 | `run_skill_action` → `render-html` | 否(sync) |
## 共享 Python(必读)
宿主共享 runtime 在 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`,并已前置到 PATH(同时设 `VIRTUAL_ENV`)。短 CLI 一条命令直接跑:
```bash
python {baseDir}/scripts/main.py health
python {baseDir}/scripts/main.py version
python {baseDir}/scripts/main.py config-path
python {baseDir}/scripts/main.py init-db
```
- 公共库来自共享 venv 的 `jiangchang-platform-kit>=1.2.11`(包名路径 `jiangchang_skill_core`);**不要 vendor** `scripts/jiangchang_skill_core/`。
- 根目录 `requirements.txt` **只声明技能特有**依赖;不要写 `jiangchang-platform-kit` / `playwright`。
- `metadata.openclaw.platform_kit_min_version` 是宿主兼容声明,**不是** pip 依赖。
- `health` 用 `collect_runtime_diagnostics` 做只读诊断。
配置:仓库 `.env.example` 为模板;用户 `.env` 在 `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{slug}/.env`。优先级:进程环境变量 > 用户 `.env` > `.env.example`。
## 面向用户问答(LLM 规则)
- 本文(`SKILL.md`)是 LLM / OpenClaw 平台读取的技能入口,**不是**用户市场说明。
- 根目录市场材料面向普通用户(见下方「文档分工」),**不是** Agent 调用契约。
当用户询问以下问题时,**必须优先读取**根目录 `README.md`(能力说明)与 `TUTORIAL.md`(逐步用法):
- 这个技能是做什么的
- 这个技能怎么用
- 适合什么场景
- 使用前要准备什么
- 执行后会得到什么
- 能不能帮我完成某个业务任务
**回答「怎么用 / 能做什么」时的要求:**
1. 「能做什么 / 场景 / 概念类 FAQ」以 `README.md`(说明 Tab)为主;「点哪里 / 说什么 / 排障」以 `TUTORIAL.md`(教程 Tab)为主。
2. **禁止**在回答中粘贴 `python …/scripts/main.py …` 或其它命令行。
3. **禁止**为答用法而去 `dir` / `ls` / 列技能目录。
4. **不要**向普通用户暴露 Playwright、DOM、Python、RPA、库表名、`error_code` 等实现细节。
5. 用户问「怎么用」时:**优先读 `TUTORIAL.md`**。
## 文档分工
| 文档 | 读者 | 用途 |
|------|------|------|
| 根目录 `README.md` | 普通用户 | 市场 **说明** Tab |
| 根目录 `TUTORIAL.md` | 普通用户 | 市场 **教程** Tab |
| 根目录 `DEMO.md` | 普通用户 | 市场 **演示** Tab |
| 根目录 `CHANGELOG.md` | 普通用户 | 市场 **更新日志** Tab |
| `SKILL.md`(本文) | LLM / OpenClaw 平台 | 技能入口、触发与运行契约摘要 |
| `references/` | Agent 编排 / 调用 | CLI、Action、schema 契约 |
| `development/` | 开发者 / AI 编程代理 | 需求、开发教程、测试、深度技术规范 |
## 目录约定
- `assets/`、`development/`、`evals/`、`references/`、`scripts/`、`tests/`
- CLI 入口固定为 `scripts/main.py`
- 业务逻辑按 `cli / db / service / util` 分层
## 技能命名与 slug
- slug:`alert-contract-expiring-hr`(`alert` 动词 + `contract-expiring` 名词 + `hr` 人事处缩写)
- name:**用户可见的中文业务名称**;`description` 是说明,不是名称。
- 目录名、默认 `{slug}.db` 使用 slug,不使用中文。
## 平台元数据
- `metadata.openclaw.developer_ids`:技能发布后的默认开发者可见用户 ID 列表。
- 开发期技能常为不公开:不加本人 ID,开发者自己也无法在市场安装自测。
- 当 `access_scope = 0`(不公开)时,平台会把 `developer_ids` 中的用户自动补写到 `skill_user_access`。
---
> **本技能开发状态:** v1.0.1 骨架已就位,业务实现细节见 `development/REQUIREMENTS.md`;具体 Python 模块由开发者后续补全。

145
TUTORIAL.md Normal file
View File

@@ -0,0 +1,145 @@
# 使用教程
本技能有三种**平行**的使用方式:
- **数据管理**:在左侧「合同台账」表上点工具栏 / 行内按钮
- **对话任务**:用自然语言让 Agent 完成同一能力
- **定时任务**:每日凌晨 02:00 自动扫描全校合同到期情况
三种方式触发同一套业务能力,结果一致,选你习惯的即可。
---
## 本技能做什么
```text
【合同电子台账 + 考核数据 + 师德数据】 → 【距到期日 90/60/30 天分级】 → 【个人任务书 + 单位清单】 → 【红头预警自动抄送】
```
- **输入**:全校教职工合同电子台账 + 近 3 年考核结果 + 师德师风考评记录
- **输出**:个人版《续聘准备任务书》+ 单位版《本月合同到期人员清单》+ 三级预警通知
- **说明**:本技能不替代人事处终审;AI 建议仅供二级单位参考,最终决定权在人事处与分管校领导
---
## 开始前
确认:
1. 已安装并登录匠厂客户端
2. 已在技能市场安装本技能(建议更新到最新版)
3. 人事系统 API 与党委教师工作部 API 凭证已配置(`HR_API_TOKEN` / `TEACHER_AFFAIRS_API_TOKEN` 已登记到本技能 `.env`)
4. 二级单位人事秘书通讯录已登记
5. 【如需】首次使用前,先在「数据管理 → 合同台账」点击一次「立即同步」载入最新合同数据
---
## 入口一:数据管理
适合:人事处合同管理员、二级单位人事秘书、喜欢点界面操作的用户。
### 左侧各表是干什么的
| 表名(中文) | 作用 |
|--------------|------|
| 合同台账 | 全校教职工合同电子台账主表,状态行可见 |
| 预警记录 | 每次扫描产生的预警条目(含黄/橙/红等级) |
| 续聘任务 | 已生成的续聘任务书记录(个人 / 单位版) |
| 派发日志 | 每一条预警的派发状态、接收人、是否已读 |
### 首次准备(若需要)
1. 进入 **数据管理** → **合同台账**
2. 点击表顶 **「立即同步」**(空表可点)
3. 成功后应看到:全校教职工合同基本信息、合同类型、起止日期、聘任岗位
### 主流程:触发一次合同到期扫描
1. 进入 **合同台账**
2. 点击表顶 **「立即扫描到期合同」**
3. 等待 30 秒~2 分钟(按全校合同规模)
4. 完成后:
- 预警记录表新增若干行(含黄/橙/红等级)
- 距到期日 30 天内的合同行进入「预警中(红色)」状态
- 教师本人收到个人版《续聘准备任务书》通知
- 二级单位人事秘书收到单位版《本月到期清单》通知
- 30 天红色预警自动抄送分管校领导
### 行级 / 批量
| 你想做什么 | 操作 |
|------------|------|
| 给某位教师生成续聘任务书 | **合同台账** 行内 → **「生成续聘任务书」** |
| 下发单位版清单 | 选中某单位的多行 → **「按单位生成清单」** |
| 标记已续签 | **合同台账** 行内 → **「标记为已续签」**(须上传续签材料) |
| 重新触发预警 | **合同台账** 行内 → **「重新触发预警」** |
---
## 入口二:对话任务
适合:临时提问、不想切换表的用户。
### 什么时候用对话
- 临时想知道某位教师的合同什么时候到期
- 想用自然语言解释「为什么 AI 建议不续聘」
- 想看「本月一共要办多少份续聘」
### 示例话术
| 你想做什么 | 可以这样说 |
|------------|------------|
| 查询某教师合同 | 「张老师的合同什么时候到期?」 |
| 触发扫描 | 「帮我扫描一下全校合同到期情况」 |
| 生成任务书 | 「给李老师生成一份续聘准备任务书」 |
| 查看本月统计 | 「本月一共要办多少份续聘?」 |
| 解释建议 | 「为什么 AI 建议王老师续聘观察?」 |
### Agent 可能追问什么
- 缺参数时:「请告诉我教师姓名 / 工号 / 合同 ID」
- 数据缺失时:「该教师近 3 年考核数据缺失,请先在党委教师工作部系统补录」
- 失败时:可改用数据管理点按钮重新触发
---
## 入口三:定时任务
### 本技能已启用定时扫描
1. 打开侧栏 **定时任务** → 新建
2. 选择本技能 → Action **「扫描即将到期合同」**
3. 默认参数:
- 扫描阈值:90 / 60 / 30 天
- 扫描时间:每日凌晨 02:00(可在定时任务面板调整)
- 抄送规则:30 天红色预警自动抄送分管校领导
4. 填写保存;可「立即运行」验证
5. 每日到点后在「预警记录」表查看本次扫描结果
---
## 主能力对照表
| 想做什么 | 数据管理 | 对话任务 | 定时任务 |
|----------|----------|----------|----------|
| 扫描全校合同到期 | 表顶 **「立即扫描到期合同」** | 「帮我扫描一下全校合同到期」 | ✅ 每日凌晨 02:00 自动 |
| 生成个人任务书 | 行内 → **「生成续聘任务书」** | 「给李老师生成一份续聘任务书」 | — |
| 下发单位清单 | 批量 → **「按单位生成清单」** | 「给 XX 学院下发本月清单」 | — |
| 标记已续签 | 行内 → **「标记为已续签」** | 「张老师合同已续签」 | — |
按钮名必须与 `assets/actions.json` 的 `label` 一致。
---
## 常见卡住怎么办
| 现象 | 你可以先试 |
|------|------------|
| 「立即扫描到期合同」点不了 | 是否已配置人事系统 API;是否装错版本 |
| 扫描很久没结果 | 看任务中心进度;合同规模 > 2000 份可能耗时较久 |
| 教师没收到任务书 | 检查教师通讯录中是否登记了正确的手机 / 邮箱 |
| 红色预警没抄送领导 | 检查分管校领导通讯录是否配置;30 天阈值是否到达 |
| 考核数据缺失 | 在党委教师工作部系统补录考核数据后,重新触发扫描 |
仍无法解决时,把「你用的入口 + 操作步骤 + 界面提示原文」发给支持人员。

119
assets/actions.json Normal file
View File

@@ -0,0 +1,119 @@
{
"schemaVersion": 1,
"skill": "alert-contract-expiring-hr",
"actions": [
{
"id": "health",
"label": "运行检查",
"description": "检查技能运行环境、人事系统 API 连通性、考核数据接口可用性。",
"placements": ["skill-detail", "agent"],
"executionProfile": "sync",
"entrypoint": {
"type": "cli",
"command": "health",
"args": []
}
},
{
"id": "version",
"label": "查看版本",
"description": "返回技能版本和机器标识。",
"placements": ["skill-detail"],
"executionProfile": "sync",
"entrypoint": {
"type": "cli",
"command": "version",
"args": []
}
},
{
"id": "config-path",
"label": "查看配置位置",
"description": "返回技能配置文件位置。",
"placements": ["skill-detail"],
"executionProfile": "sync",
"entrypoint": {
"type": "cli",
"command": "config-path",
"args": []
}
},
{
"id": "scan-expiring-contracts",
"label": "扫描即将到期合同",
"description": "扫描全校教职工合同电子台账,对距到期日 90/60/30 天的合同分别给出黄/橙/红三级预警,并自动生成个人任务书与单位清单。",
"placements": ["cron", "agent", "toolbar"],
"executionProfile": "async",
"bind": {
"tables": ["合同台账"]
},
"entrypoint": {
"type": "cli",
"command": "scan-expiring-contracts",
"args": ["--thresholds", "90,60,30"]
}
},
{
"id": "generate-renewal-task",
"label": "生成续聘任务书",
"description": "为指定合同对应的教师生成个人版《续聘准备任务书》(HTML + 通知推送),含近三年业绩摘要、AI 续聘建议、所需材料清单。",
"placements": ["row", "agent"],
"executionProfile": "sync",
"bind": {
"tables": ["合同台账"],
"selectionMode": "required",
"labelColumn": "教职工姓名"
},
"entrypoint": {
"type": "cli",
"command": "generate-renewal-task",
"args": ["--contract-id", "{row.合同ID}"]
}
},
{
"id": "dispatch-task-notice",
"label": "下发续聘任务通知单",
"description": "按二级单位或按月份生成单位版《本月合同到期人员清单及续聘建议》并推送给人事秘书。",
"placements": ["batch", "agent"],
"executionProfile": "sync",
"bind": {
"tables": ["合同台账", "预警记录"],
"selectionMode": "optional"
},
"entrypoint": {
"type": "cli",
"command": "dispatch-task-notice",
"args": ["--unit-id", "{form.unit_id}", "--month", "{form.month}"]
}
},
{
"id": "mark-contract-renewed",
"label": "标记合同已续签",
"description": "将指定合同标记为已续签(须上传续签材料),并自动解除预警。",
"placements": ["row"],
"executionProfile": "sync",
"bind": {
"tables": ["合同台账"],
"selectionMode": "required",
"labelColumn": "教职工姓名"
},
"entrypoint": {
"type": "cli",
"command": "mark-contract-renewed",
"args": ["--contract-id", "{row.合同ID}", "--material-id", "{form.material_id}"]
}
},
{
"id": "render-html",
"label": "在浏览器中打开续聘任务书",
"description": "渲染已生成的个人版《续聘准备任务书》HTML 页面,宿主 iframe 预览。",
"placements": ["skill-detail", "agent"],
"executionProfile": "sync",
"entrypoint": {
"type": "cli",
"command": "render-html",
"args": ["--task-id", "{form.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>

220
development/REQUIREMENTS.md Normal file
View File

@@ -0,0 +1,220 @@
# 合同到期智能预警与续聘辅助 — 技能需求文档
> 派生自匠厂 skill-template 主仓 `development/REQUIREMENTS.md`,对应规格书第 4.2 节「🟡 人事处 Skill 002 —— 合同到期"智能预警与续聘辅助"」。
---
## 0. 技能标识
- **拟定 slug(英文)**:`alert-contract-expiring-hr`
- **中文 name**:合同到期智能预警与续聘辅助
- **中文 description(一句话)**:每日凌晨扫描全校教职工合同电子台账,对距离到期日 90/60/30 天的合同分级预警,并自动生成《续聘准备任务书》和《本月合同到期人员清单》
- **account-manager platform_key(如有)**:N/A
- **命名形态**:标准型(动词-名词-部门缩写)
- **目标 OEM 市场 `brands`(可选)**:未写则发布后仅挂匠厂(`jiangchang`)
- **网页 RPA(如有)— 登录策略**:**不适用**(本技能不涉及网页 RPA)
## 0.1 宿主入口定位(必填)
| 主能力(Action id / CLI) | 对话 | 数据管理 | 定时 | 教程章节 | 不挂理由 |
|---------------------------|------|----------|------|----------|----------|
| `health` / `version` / `config-path` | 挂 | 不挂 | 不挂 | 入口二 | 默认必备 |
| `scan-expiring-contracts` | 挂 | toolbar(合同台账) | 挂(每日凌晨 02:00) | 入口一/入口二/入口三 | 真用 cron |
| `generate-renewal-task` | 挂 | row(合同台账) | 不挂 | 入口一/入口二 | sync |
| `dispatch-task-notice` | 挂 | batch(合同台账/预警记录) | 不挂 | 入口一/入口二 | sync |
| `mark-contract-renewed` | 不挂 | row(合同台账) | 不挂 | 入口一 | 写状态,不需 Agent |
| `render-html` | 挂 | skill-detail | 不挂 | 入口二 | HTML 报告预览 |
### 0.1.1 用户市场文档(与入口表一致)
- `README.md`(说明 Tab):能力 / 场景 / 边界
- `TUTORIAL.md`(教程 Tab):覆盖三入口(数据管理 / 对话 / 定时)
- 入口三已写「已启用定时扫描」,与本表一致
## 0.2 AI + RPA 协作与授权边界
### Agent 设计
- 用户会怎样描述本任务:「张老师的合同什么时候到期?」「本月一共要办多少份续聘?」「帮我扫描一下全校合同到期」
- 支持的用户意图 → Action 映射:
- 触发扫描 → `scan-expiring-contracts`
- 给某教师生成任务书 → `generate-renewal-task`
- 下发单位清单 → `dispatch-task-notice`
- 标记已续签 → `mark-contract-renewed`
- 预览任务书 → `render-html`
- 每种意图必须提取的参数:`contract_id` / `unit_id` / `month` / `material_id`
- 缺少参数时必须追问:「请告诉我教师姓名 / 工号 / 合同 ID」
- 哪些动作必须先展示对象、数量和影响,再取得明确确认:
- `mark-contract-renewed` 状态变更(确认行 + 续签材料)
- `dispatch-task-notice` 提示将推送至 N 位人事秘书
- Agent 最终如何解释成功、部分成功、失败、等待人工和停止:
- 成功:「已扫描全校 X 份合同,发现 Y 份 90 天内到期,其中 Z 份 30 天红色预警」
- 部分成功:「已完成扫描,但 N 位教师的考核数据缺失,已标记为数据待补」
- 失败:「人事系统 API 调用失败(错误码 XXX),已停止扫描并保留上一次的预警记录」
### 授权与平台边界
- 目标浏览器 / 桌面软件 / 手机 App:**N/A**(本技能不涉及 RPA)
- 账号、设备、数据和业务权限由谁授权:人事处统一授权;考核 / 师德数据由党委教师工作部统一开通
- 是否有获得授权的官方 API:人事系统 API(合同台账)、党委教师工作部 API(考核 + 师德)、企业微信推送 API(教师与人事秘书通讯)
- 目标平台规则、合同或客户约定中的自动化边界:不得越权读取非本校教职工数据;不得向教师个人透露 AI 续聘建议(仅单位版清单展示)
- 明确禁止的实现路线:**所有破解 / 反编译 / 逆向 / 私有协议 / 抓包拼隐藏接口 / Hook 或注入 / 验证码破解 / 设备指纹伪造 / 自动换号 均禁止**
- 正常界面无法闭环时的上报与停止条件:人事系统 API 不可用超过 60 分钟 → 停止扫描并向人事处合同管理员上报
### RPA 风控设计
- 执行端:**N/A**
## 0.3 业务行生命周期
本技能对「合同台账」表中的每一份合同,遵守 `references/WORK_ITEM_STATUS.md` 角色约束:
| 角色 | 本技能状态值 | 说明 |
|------|--------------|------|
| 可操作(operable) | 可续聘 | 默认唯一可被 `scan-expiring-contracts` 捞起 |
| 进行中(in_progress) | 预警中(黄/橙/红) | 抢占后、结束前 |
| 成功类终态 | 已续签 | 人事处确认续签材料齐全 |
| 失败类终态 | 已驳回 / 已过期 | 默认不可再处理;失败原因字段:驳回理由 / 过期说明 |
| 跳过类终态 | 已撤回 | 教师或单位主动撤回续聘申请 |
> 重做靠人工将"已续签 / 已驳回 / 已过期"改回"可续聘",不靠 AI 自动覆盖。
---
## 1. 文档目标
本文档用于明确 `alert-contract-expiring-hr` 的研发范围、交付标准、依赖关系和验收要求。
## 2. 业务背景
当前用户在「全校教职工合同管理」场景中:
- **现状**:人事处合同管理员每月需人工翻查 1000+ 份合同,年度考核结果出后才仓促通知续签
- **痛点**:
- **效率低**:1 份合同的预警与材料准备需人工 2~3 小时
- **容易遗漏**:试用期合同、跨年度合同尤其容易漏检
- **劳动纠纷风险**:合同逾期未续签导致劳动争议
- **年终突击**:每年 12 月人事处集中突击签合同,工作量暴增
- **目标**:自动完成「合同到期扫描 + 三级预警 + 任务书预填 + 单位清单派发」,输出「个人版任务书 + 单位版清单 + 预警通知」
## 3. 开发目标
- 解析合同电子台账(含合同类型、起止日期、聘任岗位、试用期)
- 拉取教师近 3 年年度考核结果 + 师德师风考评记录
- 按距到期日 90/60/30 天分级,触发三级预警
- 自动生成个人版《续聘准备任务书》(含业绩摘要 + AI 续聘建议 + 材料清单)
- 自动生成单位版《本月合同到期人员清单及续聘建议》
- 30 天红色预警自动抄送分管校领导
- 写入业务表(合同台账 / 预警记录 / 续聘任务 / 派发日志)
- 支持失败可诊断(稳定错误码 + 中文 message)
- 完成 CLI 入口与 `health` / `version` / `init-db` 最小可运行验证
- 完成正式环境发布与安装验证
## 4. 功能范围
### 必须实现
- 支持 `python scripts/main.py health` / `version` / `config-path` / `init-db`
- 支持 `python scripts/main.py scan-expiring-contracts --thresholds 90,60,30`(async)
- 支持 `python scripts/main.py generate-renewal-task --contract-id <id>`
- 支持 `python scripts/main.py dispatch-task-notice --unit-id <id> --month <YYYY-MM>`
- 支持 `python scripts/main.py mark-contract-renewed --contract-id <id> --material-id <id>`
- 支持 `python scripts/main.py render-html --task-id <id>`
- 完成 §0.1 入口定位表,并按表落实已挂入口
- 定时任务接入宿主 `cron` 调度系统
### 可后续扩展
- 试用期合同不通过的特殊处理流程
- 续聘材料 OCR 自动归档
- 多语言合同(中英双语)支持
## 5. 非功能要求
- 目录结构必须符合当前 `skill-template` 规范
- 入口必须统一为 `scripts/main.py`
- 用户市场四 Tab 写在根 `README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`
- 输出格式应尽量机读友好;错误前缀统一为 `ERROR:`
- Windows 环境下需保证 UTF-8 输出兼容
- 必须具备基本日志能力;敏感字段脱敏(教师身份证号、银行卡号等不写入日志)
- 全校 1000+ 份合同扫描耗时 ≤ 2 分钟
## 6. 输入输出要求
### 输入
- `contract_id`:合同主键(必填,字符串)
- `unit_id`:二级单位主键(仅 `dispatch-task-notice` 必填)
- `month`:月份(仅 `dispatch-task-notice` 必填,格式 YYYY-MM)
- `material_id`:续签材料 ID(仅 `mark-contract-renewed` 必填)
- `thresholds`:扫描阈值(默认 `90,60,30`,可配置)
- 依赖系统:
- 人事系统 API:合同电子台账
- 党委教师工作部 API:考核 + 师德数据
- 企业微信推送 API:教师与人事秘书通讯
- 环境变量(`.env`):
- `HR_API_BASE_URL`:人事系统 API 入口
- `HR_API_TOKEN`:调用凭证
- `TEACHER_AFFAIRS_API_BASE_URL`:党委教师工作部 API 入口
- `TEACHER_AFFAIRS_API_TOKEN`:调用凭证
- `WECOM_API_BASE_URL`:企业微信 API 入口
- `WECOM_API_TOKEN`:调用凭证
### 输出
- 成功结果:`scan-expiring-contracts` 返回结构化 JSON `{scanned_count, alert_yellow_count, alert_orange_count, alert_red_count, generated_task_count}`
- 错误输出:`ERROR:` 稳定码 + 中文 message(如 `ERROR: HR_API_TIMEOUT 合同台账读取超时`)
- 数据库记录:写入 `合同台账` / `预警记录` / `续聘任务` / `派发日志`
- HTML 报告:个人版《续聘准备任务书》落到 `html/`
## 7. 依赖的兄弟技能或外部系统
### 兄弟技能依赖
- 无强制依赖
### 外部系统依赖
- **人事系统 API**(合同电子台账):必须
- **党委教师工作部 API**(考核 + 师德):必须
- **企业微信推送 API**:必须
### Python 包依赖(requirements.txt)
- HTTP 请求:`requests>=2.31.0,<3`
- 日期处理:`python-dateutil>=2.8.0,<3`
- HTML 渲染:`jinja2>=3.1.0,<4`
- `jiangchang-platform-kit`、`playwright` 等公共能力由宿主共享 runtime 提供
## 8. 不在本次范围内的内容
- 本次不实现试用期合同不通过的自动化处分流程
- 本次不集成续聘材料 OCR 识别
- 本次不做合同电子签署(仍走线下 OA 流程)
- 本次不兼容旧模板历史结构
## 9. 验收标准
- 代码结构符合模板规范;`SKILL.md` slug 与 `constants.SKILL_SLUG` 一致
- `health` / `version` / `config-path` / `init-db` 命令执行正常
- `scan-expiring-contracts` 在含 1000+ 份合同的样例库中完成一次完整扫描,耗时 ≤ 2 分钟
- 业务表写入和查询符合 `references/SCHEMA.md`
- 30 天红色预警自动抄送分管校领导功能验证通过
- `SKILL.md` 的 `developer_ids` 已配置为开发者本人匠厂用户 ID
- 已人工核验授权与技术路线:无破解、逆向、私有协议、验证码绕过、Hook / 注入、自动换号
- 安装后按 §0.1 入口定位表完成宿主多入口验收
- cron 每日定时扫描稳定运行 7 天无故障
## 10. 开发注意事项
- 只修改当前 skill 仓库,不要改动无关兄弟项目
- 人事系统 API 不可用时停止并上报;不得擅自改走破解或绕过鉴权的路线
- 教师身份证号、银行卡号等敏感字段严禁写入日志
- 不得向教师个人透露 AI 续聘建议(仅单位版清单展示)
- `cli` 只做参数解析;核心逻辑在 `service`;兄弟 skill 调用集中封装
## 11. 变更记录
| 日期 | 版本 | 变更人 | 变更内容 |
|------|------|--------|----------|
| 2026-09-14 | v1.0.1 | 匠厂 | 初版需求文档,骨架就位 |

8
pytest.ini Normal file
View File

@@ -0,0 +1,8 @@
[pytest]
testpaths = tests
python_files = test_*.py *_test.py
python_classes = Test*
python_functions = test_*
norecursedirs = .git __pycache__ .pytest_cache integration desktop samples fixtures artifacts diagnostics
filterwarnings =
ignore::DeprecationWarning

424
release.ps1 Normal file
View File

@@ -0,0 +1,424 @@
<#
.SYNOPSIS
Local git release for skill-template repo.
.DESCRIPTION
Handles git commit / push / semver tag / push tag only.
Encryption, ZIP packaging, and marketplace sync are done by CI
(.github/workflows/release_skill.yaml → reusable-release-skill.yaml@main).
POLICY-RELEASE-001: before tagging, requires SKILL.md version and
scripts/util/constants.py SKILL_VERSION to equal the next tag (without prefix).
release.ps1 does not auto-edit those fields.
Requires: git, PowerShell 5+
After fetch, runs default tests against the current working tree (before auto-commit):
python tests/run_tests.py -v
Use -SkipTests only for emergency diagnostics. -DryRun still runs tests.
#>
[CmdletBinding()]
param(
[string]$Prefix = "v",
[string]$Message,
[switch]$AutoCommit,
[switch]$RequireClean,
[string]$CommitMessage,
[switch]$DryRun,
[switch]$SkipTests
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# Default tag message is 「正式发布」 from UTF-8 bytes (not a Chinese literal in
# this file). Windows PowerShell 5.1 parses UTF-8 scripts without BOM as the
# system ANSI code page, which would turn a source literal into 「姝e紡鍙戝竷」.
if (-not $PSBoundParameters.ContainsKey("Message") -or [string]::IsNullOrWhiteSpace($Message)) {
$Message = [System.Text.Encoding]::UTF8.GetString([byte[]](0xE6, 0xAD, 0xA3, 0xE5, 0xBC, 0x8F, 0xE5, 0x8F, 0x91, 0xE5, 0xB8, 0x83))
}
function Remove-GitNoise {
param([string[]]$Lines)
return @($Lines | Where-Object {
$_ -and ($_ -notmatch '^warning:\s+unable to access ')
})
}
function Convert-GitOutputLines {
param($Output)
return @(
@($Output) | ForEach-Object {
if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() }
else { "$_" }
}
)
}
# Call git.exe directly. Never wrap with `cmd /c`: on Chinese Windows, cmd
# re-encodes the command line through the OEM code page.
# Also never pass Chinese via `git -m`: Windows PowerShell 5.1 encodes native
# argv using the console code page, so 「正式发布」 becomes 「姝e紡鍙戝竷」
# in the annotated tag. Commit/tag messages go through a UTF-8 file + `-F`.
function Invoke-GitRaw {
param([Parameter(Mandatory = $true)][string[]]$GitArgs)
$prev = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$output = & git @GitArgs 2>&1
$code = $LASTEXITCODE
} finally {
$ErrorActionPreference = $prev
}
return [pscustomobject]@{
ExitCode = $code
Lines = @(Remove-GitNoise -Lines @(Convert-GitOutputLines -Output $output))
}
}
function Invoke-Git {
param([Parameter(Mandatory = $true)][string[]]$GitArgs)
Write-Host (">> git " + ($GitArgs -join " ")) -ForegroundColor DarkGray
$r = Invoke-GitRaw -GitArgs $GitArgs
if ($r.ExitCode -ne 0) {
throw "git command failed: git $($GitArgs -join ' ')"
}
}
function Invoke-GitWithMessageFile {
param(
[Parameter(Mandatory = $true)][string[]]$GitArgsPrefix,
[Parameter(Mandatory = $true)][string]$Message
)
$msgFile = Join-Path ([System.IO.Path]::GetTempPath()) ("jiangchang-git-msg-" + [guid]::NewGuid().ToString("N") + ".txt")
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($msgFile, ($Message.TrimEnd() + "`n"), $utf8NoBom)
try {
Invoke-Git -GitArgs (@("-c", "i18n.commitEncoding=utf-8") + $GitArgsPrefix + @("-F", $msgFile))
} finally {
Remove-Item -LiteralPath $msgFile -Force -ErrorAction SilentlyContinue
}
}
function Get-GitOutput {
param([Parameter(Mandatory = $true)][string[]]$GitArgs)
$r = Invoke-GitRaw -GitArgs $GitArgs
if ($r.ExitCode -ne 0) {
throw "git command failed: git $($GitArgs -join ' ')"
}
return @($r.Lines)
}
function Test-Repo {
$r = Invoke-GitRaw -GitArgs @("rev-parse", "--is-inside-work-tree")
if ($r.ExitCode -ne 0) {
return $false
}
return (($r.Lines | Select-Object -First 1) -eq "true")
}
function Get-CurrentBranch {
$b = (Get-GitOutput -GitArgs @("branch", "--show-current") | Select-Object -First 1).Trim()
return $b
}
function Get-StatusPorcelain {
$lines = @(Get-GitOutput -GitArgs @("status", "--porcelain"))
return $lines
}
function Parse-SemVerTag {
param(
[string]$Tag,
[string]$TagPrefix
)
$escaped = [regex]::Escape($TagPrefix)
$m = [regex]::Match($Tag, "^${escaped}(\d+)\.(\d+)\.(\d+)$")
if (-not $m.Success) { return $null }
return [pscustomobject]@{
Raw = $Tag
Major = [int]$m.Groups[1].Value
Minor = [int]$m.Groups[2].Value
Patch = [int]$m.Groups[3].Value
}
}
function Get-NextTag {
param([string]$TagPrefix)
$tags = Get-GitOutput -GitArgs @("tag", "--list")
$parsed = @()
foreach ($t in $tags) {
$t = $t.Trim()
if (-not $t) { continue }
$obj = Parse-SemVerTag -Tag $t -TagPrefix $TagPrefix
if ($null -ne $obj) { $parsed += $obj }
}
if ($parsed.Count -eq 0) {
return "${TagPrefix}1.0.1"
}
$latest = $parsed | Sort-Object Major, Minor, Patch | Select-Object -Last 1
return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)"
}
function Read-SkillMdVersion {
param([Parameter(Mandatory = $true)][string]$SkillRoot)
$path = Join-Path $SkillRoot "SKILL.md"
if (-not (Test-Path -LiteralPath $path)) {
return $null
}
$text = Get-Content -LiteralPath $path -Raw -Encoding UTF8 -ErrorAction SilentlyContinue
if ([string]::IsNullOrWhiteSpace($text)) {
return $null
}
$m = [regex]::Match($text, '(?m)^version:\s*["'']?([0-9]+\.[0-9]+\.[0-9]+)["'']?\s*$')
if (-not $m.Success) {
return $null
}
return $m.Groups[1].Value.Trim()
}
function Read-SkillConstantsVersion {
param([Parameter(Mandatory = $true)][string]$SkillRoot)
$path = Join-Path $SkillRoot "scripts\util\constants.py"
if (-not (Test-Path -LiteralPath $path)) {
return $null
}
$text = Get-Content -LiteralPath $path -Raw -Encoding UTF8 -ErrorAction SilentlyContinue
if ([string]::IsNullOrWhiteSpace($text)) {
return $null
}
$m = [regex]::Match($text, '(?m)^SKILL_VERSION\s*=\s*["'']([0-9]+\.[0-9]+\.[0-9]+)["'']')
if (-not $m.Success) {
return $null
}
return $m.Groups[1].Value.Trim()
}
# POLICY-RELEASE-001: marketplace latest (git tag) must equal package manifest versions.
function Assert-SkillReleaseVersionAlignment {
param(
[Parameter(Mandatory = $true)][string]$SkillRoot,
[Parameter(Mandatory = $true)][string]$NextTag,
[Parameter(Mandatory = $true)][string]$TagPrefix
)
$skillMdPath = Join-Path $SkillRoot "SKILL.md"
if (-not (Test-Path -LiteralPath $skillMdPath)) {
return
}
$expected = $NextTag
if ($TagPrefix -and $expected.StartsWith($TagPrefix)) {
$expected = $expected.Substring($TagPrefix.Length)
}
$expected = $expected.Trim()
if ($expected -notmatch '^\d+\.\d+\.\d+$') {
throw "Release check failed (POLICY-RELEASE-001): cannot derive semver from Next tag '$NextTag'."
}
$mdVersion = Read-SkillMdVersion -SkillRoot $SkillRoot
$constVersion = Read-SkillConstantsVersion -SkillRoot $SkillRoot
$problems = @()
if ([string]::IsNullOrWhiteSpace($mdVersion)) {
$problems += "SKILL.md missing top-level version: X.Y.Z"
}
elseif ($mdVersion -ne $expected) {
$problems += "SKILL.md version=$mdVersion (want $expected)"
}
if ([string]::IsNullOrWhiteSpace($constVersion)) {
$problems += "scripts/util/constants.py missing SKILL_VERSION = `"X.Y.Z`""
}
elseif ($constVersion -ne $expected) {
$problems += "SKILL_VERSION=$constVersion (want $expected)"
}
if ($problems.Count -gt 0) {
throw (
"Release aborted (POLICY-RELEASE-001): Next tag=$NextTag requires manifest version $expected, but " +
($problems -join "; ") +
". Bump SKILL.md version and scripts/util/constants.py SKILL_VERSION to $expected (and CHANGELOG), then re-run release.ps1. " +
"release.ps1 does not auto-edit these fields."
)
}
Write-Host "Version alignment OK: tag=$NextTag == SKILL.md=$mdVersion == SKILL_VERSION=$constVersion" -ForegroundColor DarkGray
}
function Assert-SkillReleasePackagingSources {
param([Parameter(Mandatory = $true)][string]$SkillRoot)
$scriptsDir = Join-Path $SkillRoot "scripts"
$mainPy = Join-Path $scriptsDir "main.py"
if (-not (Test-Path -LiteralPath $mainPy)) {
return
}
$text = Get-Content -LiteralPath $mainPy -Raw -Encoding UTF8 -ErrorAction SilentlyContinue
if ([string]::IsNullOrWhiteSpace($text)) {
return
}
$need = @()
if ($text -match '(?m)^\s*from\s+cli\.') { $need += 'cli' }
if ($text -match '(?m)^\s*import\s+cli\b') { $need += 'cli' }
if ($text -match '(?m)^\s*from\s+service\.') { $need += 'service' }
if ($text -match '(?m)^\s*import\s+service\b') { $need += 'service' }
if ($text -match '(?m)^\s*from\s+db\.') { $need += 'db' }
if ($text -match '(?m)^\s*import\s+db\b') { $need += 'db' }
if ($text -match '(?m)^\s*from\s+util\.') { $need += 'util' }
if ($text -match '(?m)^\s*import\s+util\b') { $need += 'util' }
foreach ($p in ($need | Select-Object -Unique)) {
$folder = Join-Path $scriptsDir $p
if (-not (Test-Path -LiteralPath $folder)) {
throw "Release check failed: scripts/main.py imports from '$p' but folder is missing: $folder"
}
}
$pyFiles = @(Get-ChildItem -LiteralPath $scriptsDir -Filter *.py -Recurse -File -ErrorAction SilentlyContinue)
Write-Host "Packaging check: $($pyFiles.Count) Python file(s) under scripts/ (CI will obfuscate all recursively)." -ForegroundColor DarkGray
}
function Invoke-DefaultTests {
param(
[Parameter(Mandatory = $true)][string]$SkillRoot,
[switch]$Skip
)
if ($Skip) {
Write-Host "WARNING: Skipping tests before release. This should only be used for emergency diagnostics." -ForegroundColor Yellow
return
}
$runTests = Join-Path $SkillRoot "tests\run_tests.py"
if (-not (Test-Path -LiteralPath $runTests)) {
throw "Release check failed: tests/run_tests.py not found."
}
Write-Host ">> python tests/run_tests.py -v" -ForegroundColor DarkGray
& python $runTests -v
if ($LASTEXITCODE -ne 0) {
throw "Release aborted: default tests failed (python tests/run_tests.py -v)."
}
}
function Ensure-CleanOrAutoCommit {
param(
[switch]$DoAutoCommit,
[switch]$NeedClean,
[switch]$IsDryRun,
[string]$Msg
)
$status = @(Get-StatusPorcelain)
if ($status.Length -eq 0) { return }
if ($NeedClean) {
Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow
(Invoke-GitRaw -GitArgs @("status", "--short")).Lines | ForEach-Object { Write-Host $_ }
throw "Abort: dirty working tree."
}
$commitMsg = $Msg
if ([string]::IsNullOrWhiteSpace($commitMsg)) {
$commitMsg = "chore: auto release commit ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))"
}
if (-not $DoAutoCommit) {
Write-Host "Detected uncommitted changes, auto-committing before release..." -ForegroundColor Yellow
}
if ($IsDryRun) {
Write-Host "[DryRun] Would run: git add -A" -ForegroundColor Yellow
Write-Host "[DryRun] Would run: git commit -m `"$commitMsg`"" -ForegroundColor Yellow
return
}
Invoke-Git -GitArgs @("add", "-A")
Invoke-GitWithMessageFile -GitArgsPrefix @("commit") -Message $commitMsg
}
try {
Write-Host "=== Release Script Start ===" -ForegroundColor Cyan
if (-not (Test-Repo)) {
throw "Current directory is not a git repository."
}
$branch = Get-CurrentBranch
if ([string]::IsNullOrWhiteSpace($branch)) {
throw "Unable to determine current branch."
}
if ($branch -notin @("main", "master")) {
throw "Current branch is '$branch'. Release is only allowed from main/master."
}
if ($DryRun) {
Write-Host "[DryRun] Would run: git fetch --tags --prune origin" -ForegroundColor Yellow
} else {
Invoke-Git -GitArgs @("fetch", "--tags", "--prune", "origin")
}
$skillRoot = (Get-GitOutput -GitArgs @("rev-parse", "--show-toplevel") | Select-Object -First 1).Trim()
if (Test-Path -LiteralPath (Join-Path $skillRoot "SKILL.md")) {
Assert-SkillReleasePackagingSources -SkillRoot $skillRoot
}
$nextTag = Get-NextTag -TagPrefix $Prefix
Write-Host "Next tag: $nextTag" -ForegroundColor Green
Assert-SkillReleaseVersionAlignment -SkillRoot $skillRoot -NextTag $nextTag -TagPrefix $Prefix
$existing = @(Get-GitOutput -GitArgs @("tag", "--list", $nextTag))
if ($existing.Length -gt 0) {
throw "Tag already exists: $nextTag"
}
Invoke-DefaultTests -SkillRoot $skillRoot -Skip:$SkipTests
Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage
$upstream = Invoke-GitRaw -GitArgs @("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
$hasUpstream = ($upstream.ExitCode -eq 0)
if ($DryRun) {
if ($hasUpstream) {
Write-Host "[DryRun] Would run: git push" -ForegroundColor Yellow
} else {
Write-Host "[DryRun] Would run: git push -u origin $branch" -ForegroundColor Yellow
}
} else {
if ($hasUpstream) {
Invoke-Git -GitArgs @("push")
} else {
Invoke-Git -GitArgs @("push", "-u", "origin", $branch)
}
}
if ($DryRun) {
Write-Host "[DryRun] Would run: git tag -a $nextTag -F <utf8-msg-file> ($Message)" -ForegroundColor Yellow
Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow
Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan
exit 0
}
Invoke-GitWithMessageFile -GitArgsPrefix @("tag", "-a", $nextTag) -Message $Message
Invoke-Git -GitArgs @("push", "origin", $nextTag)
Write-Host "Release success: $nextTag" -ForegroundColor Green
Write-Host "=== Release Script Done ===" -ForegroundColor Cyan
exit 0
}
catch {
Write-Host "Release failed: $($_.Exception.Message)" -ForegroundColor Red
exit 1
}

15
requirements.txt Normal file
View File

@@ -0,0 +1,15 @@
# 合同到期智能预警与续聘辅助 - 技能特有依赖
# jiangchang-platform-kit / playwright 等公共能力由宿主共享 runtime 提供,不要写入此文件
# HTTP 请求
requests>=2.31.0,<3
# 日期处理
python-dateutil>=2.8.0,<3
# HTML 模板渲染(个人版任务书)
jinja2>=3.1.0,<4
# PDF 生成(单位版清单)
# weasyprint>=60.0,<61
# reportlab>=4.0.0,<5

1
scripts/cli/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""scripts.cli - CLI 参数解析层(占位)"""

8
scripts/db/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
"""scripts.db - 数据库访问层(占位)
按 references/SCHEMA.md 实现表与字段:
- 制度送审稿
- 冲突条款清单
- 制度库索引
- 审查任务日志
"""

91
scripts/main.py Normal file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""合同到期智能预警与续聘辅助 - CLI 入口(占位骨架)"""
import sys
from pathlib import Path
# 占位:开发者后续接入 jiangchang_skill_core
# from jiangchang_skill_core import ...
BASE_DIR = Path(__file__).resolve().parent.parent
def health():
"""运行检查(占位)。"""
print(f"alert-contract-expiring-hr health check (placeholder)")
print(f"base_dir: {BASE_DIR}")
return 0
def version():
"""查看版本(占位)。"""
print("alert-contract-expiring-hr v1.0.1")
return 0
def config_path():
"""查看配置位置(占位)。"""
print(f"{BASE_DIR / '.env'}")
return 0
def init_db():
"""初始化数据库(占位)。"""
print("init-db: 后续由开发者按 references/SCHEMA.md 实现")
return 0
def scan_expiring_contracts(thresholds: str = "90,60,30"):
"""扫描即将到期合同(async)。"""
print(f"scan-expiring-contracts: thresholds={thresholds} (待开发者实现)")
return 0
def generate_renewal_task(contract_id: str):
"""生成续聘任务书。"""
print(f"generate-renewal-task: contract_id={contract_id} (待开发者实现)")
return 0
def dispatch_task_notice(unit_id: str = "", month: str = ""):
"""下发续聘任务通知单。"""
print(f"dispatch-task-notice: unit_id={unit_id} month={month} (待开发者实现)")
return 0
def mark_contract_renewed(contract_id: str, material_id: str):
"""标记合同已续签。"""
print(f"mark-contract-renewed: contract_id={contract_id} material_id={material_id} (待开发者实现)")
return 0
def render_html(task_id: str):
"""在浏览器中打开续聘任务书。"""
print(f"render-html: task_id={task_id} (待开发者实现)")
return 0
COMMANDS = {
"health": health,
"version": version,
"config-path": config_path,
"init-db": init_db,
"scan-expiring-contracts": scan_expiring_contracts,
"generate-renewal-task": generate_renewal_task,
"dispatch-task-notice": dispatch_task_notice,
"mark-contract-renewed": mark_contract_renewed,
"render-html": render_html,
}
def main(argv=None):
argv = argv or sys.argv[1:]
if not argv or argv[0] not in COMMANDS:
print("Usage: python scripts/main.py <command>")
print("Commands:", ", ".join(COMMANDS.keys()))
return 1
return COMMANDS[argv[0]](*argv[1:])
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,9 @@
"""scripts.service - 业务核心逻辑层(占位)
按 development/REQUIREMENTS.md §3 开发目标实现:
- 条款解析与向量化
- 制度库语义比对
- 程序合规体检
- 重复发文检测
- 报告生成与批注回写
"""

1
scripts/util/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""scripts.util - 工具函数层(占位)"""