Files
alert-contract-expiring-hr/scripts/service/task_generator.py
taojianmin c05b765b9d
All checks were successful
技能自动化发布 / release (push) Successful in 6s
fix: require strictly increasing renewal scores
2026-09-14 22:42:22 +08:00

186 lines
6.5 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.1:
- AI 决策改为调 llm_decision.decide_suggestion(LLM 优先 / 规则降级)
- reason 文案统一改为「已结合材料包(含 X 个文件,Y 字内容)综合判断:…」
- 新增 decision_source / materials_summary 字段写入 renewal_tasks
- suggest() 函数保留(给 llm_decision 降级调用)
"""
from __future__ import annotations
import sqlite3
from db import repository as repo
from service import llm_decision
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 年业绩规则生成续聘建议(本地降级版,给 llm_decision 调用)。
返回 (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 _teacher_info(contract: sqlite3.Row, alert_level: str | None, days: int | None) -> dict:
return {
"teacher_name": contract["teacher_name"],
"unit_name": contract["unit_name"],
"position": contract["position"],
"contract_start": contract["contract_start"],
"contract_end": contract["contract_end"],
"days_to_expire": days if days is not None else "?",
"alert_level": alert_level or "?",
}
def generate_task_for_contract(
conn: sqlite3.Connection,
contract: sqlite3.Row,
alert_id: str,
task_id: str,
) -> dict | None:
"""为单份合同生成 renewal_task。
返回写入库的 task 摘要 dict(不入库时返回 None)。
v1.1.1:AI 决策走 llm_decision.decide_suggestion;
材料摘要从 materials_content 表读取。
"""
perf = repo.list_performance_by_contract(conn, contract["contract_id"])
summary = summarize(perf)
# 读材料摘要(v1.1.1)
materials_summary, materials_files = repo.summarize_materials(
conn, contract["contract_id"]
)
# 查 alert(拿 days_to_expire / level)
alert = conn.execute(
"SELECT * FROM alerts WHERE alert_id = ?", (alert_id,)
).fetchone() if alert_id else None
days = alert["days_to_expire"] if alert else None
level = alert["alert_level"] if alert else None
# AI 决策(LLM 优先,规则降级)
suggestion, reason, decision_source = llm_decision.decide_suggestion(
perf_records=perf,
materials_summary=materials_summary,
materials_files=materials_files,
teacher_info=_teacher_info(contract, level, days),
)
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),
decision_source=decision_source,
materials_summary=materials_summary,
)
logger.info(
"task_generator: %s contract=%s → %s source=%s (%s)",
task_id, contract["contract_id"], suggestion, decision_source, reason,
)
return {
"task_id": task_id,
"contract_id": contract["contract_id"],
"ai_suggestion": suggestion,
"suggestion_reason": reason,
"decision_source": decision_source,
}
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)