Files
alert-contract-expiring-hr/scripts/service/task_generator.py
taojianmin 06c053d7d7 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>
2026-09-14 14:17:43 +08:00

146 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""任务书生成器:基于规则生成 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)