新增(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>
146 lines
4.9 KiB
Python
146 lines
4.9 KiB
Python
"""任务书生成器:基于规则生成 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)
|