新增:
- scripts/service/platform_collector.py:Playwright RPA(持久化 browser profile 复用登录态),抓合同表(姓名/单位/到期日/剩余天数/预警/建议),逐行点"续聘材料包"按钮下载 PDF/Word 到 data/materials/
- scripts/service/material_parser.py:PDF/Word 文本提取(pypdf → pdf2image + pytesseract OCR 扫描件兜底;python-docx 抽 docx;antiword 兜底 .doc)
- scripts/service/llm_decision.py:AI 决策统一入口(优先调宿主 jiangchang_skill_core.llm_manager.call_llm_json;本地降级到规则版;输出 decision_source ∈ {llm, rules, rules_no_materials})
- scripts/db/migrations.py:idempotent ALTER/CREATE,建 materials_content 表 + renewal_tasks 加 decision_source / materials_summary 列
修改:
- scripts/db/connection.py:init_db() 自动调 apply_all() 应用 v1.1.1 迁移
- scripts/db/repository.py:加 insert_material / list_materials_for_contract / list_pending_materials / summarize_materials / get_task_full
- scripts/service/task_generator.py:AI 决策统一改为调 llm_decision.decide_suggestion(materials_summary=...);reason 文案加「已结合材料包(X 份文件,Y 字 / 待解析)综合判断:...」
- scripts/service/html_renderer.py:ctx 加 DECISION_SOURCE(LLM 推理 / 本地规则 / 本地规则(无材料))+ MATERIALS_COUNT
- scripts/templates/task.html.j2:footer 加 "AI 建议来源:{{DECISION_SOURCE}} · 已审阅材料 {{MATERIALS_COUNT}} 份"
- scripts/cli/app.py:新增 2 个 CLI(collect-from-platform / parse-materials);现有 9 个不动
- requirements.txt:playwright / pypdf / python-docx / pdf2image / pytesseract / Pillow
- .env.example:SMARTCAMPUS_BASE_URL / SMARTCAMPUS_PROFILE_DIR / MATERIALS_DOWNLOAD_DIR / OCR_LANG / LLM_MODEL_NAME / LLM_API_BASE_URL / LLM_API_TOKEN
- SKILL.md:version 1.1.0 → 1.1.1;用户意图路由表加 2 行;新增「数据来源」段(三层来源 + AI 决策透明化)
- README.md:使用前要准备 → 加 playwright install chromium 步骤
- CHANGELOG.md:v1.1.1 条目
- scripts/util/constants.py:SKILL_VERSION 1.1.0 → 1.1.1
本地验证:11 个 CLI 全部 exit 0;decision_source=rules;reason 含「已结合材料包(6 份文件,待解析)」;HTML footer 含决策来源。
Co-Authored-By: Claude Code <noreply@anthropic.com>
186 lines
6.5 KiB
Python
186 lines
6.5 KiB
Python
"""任务书生成器:基于规则生成 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)
|