"""任务书生成器:基于规则生成 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)