新增:
- 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>
153 lines
5.2 KiB
Python
153 lines
5.2 KiB
Python
"""AI 续聘建议统一入口:优先 LLM,本地降级规则版。
|
||
|
||
宿主环境(jiangchang_skill_core 可用):
|
||
调用 jiangchang_skill_core.llm_manager.call_llm_json,
|
||
把材料文本摘要 + 业绩数据 + 教师信息组成 prompt。
|
||
|
||
本地(jiangchang_skill_core 不可用):
|
||
降级到 task_generator 规则版(基于分数阈值)。
|
||
reason 文案加上「已结合材料包(含 Y 个文件,X 字内容)」前缀。
|
||
|
||
返回值:
|
||
(suggestion, reason, decision_source)
|
||
decision_source ∈ {"llm", "rules", "rules_no_materials"}
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sqlite3
|
||
from typing import Tuple
|
||
|
||
from service import task_generator
|
||
from util.constants import DEFAULT_MATERIALS, SUGGESTIONS
|
||
from util.logging_setup import get_skill_logger
|
||
|
||
logger = get_skill_logger()
|
||
|
||
SYSTEM_PROMPT = """你是高校人事处续聘评审助手。请根据教师信息和已提交的续聘材料包内容,判断该教师是否建议续聘。
|
||
|
||
要求:
|
||
1. 严格基于材料文本内容判断;缺少关键材料的请在 reason 里指出
|
||
2. suggestion 必须从这三项中选一:
|
||
- "renew":建议续聘
|
||
- "observe":常规续聘评审(建议复核)
|
||
- "non_renew":不建议续聘
|
||
3. reason 用 1 句话,不超过 30 字
|
||
4. 输出严格 JSON:{"suggestion": "...", "reason": "..."}
|
||
"""
|
||
|
||
|
||
def _build_user_prompt(
|
||
teacher_info: dict,
|
||
perf_records: list[sqlite3.Row],
|
||
materials_summary: str,
|
||
materials_files: list[str],
|
||
) -> str:
|
||
perf_lines = []
|
||
for p in perf_records:
|
||
perf_lines.append(
|
||
f"- {p['year']} 年度:综合 {p['performance_score']:.0f} "
|
||
f"(教学 {p['teaching_score']:.0f} / 科研 {p['research_score']:.0f})"
|
||
f",师德 {p['moral_teaching_score']:.0f}"
|
||
)
|
||
perf_block = "\n".join(perf_lines) if perf_lines else "(无业绩数据)"
|
||
|
||
checklist_bullets = "\n".join(f"- {m}" for m in materials_files) if materials_files else "(无材料文件)"
|
||
|
||
return f"""## 教师信息
|
||
- 姓名:{teacher_info.get('teacher_name', '?')}
|
||
- 单位:{teacher_info.get('unit_name', '?')}
|
||
- 岗位:{teacher_info.get('position', '?')}
|
||
- 合同起止:{teacher_info.get('contract_start', '?')} ~ {teacher_info.get('contract_end', '?')}
|
||
- 距到期:{teacher_info.get('days_to_expire', '?')} 天
|
||
- 预警等级:{teacher_info.get('alert_level', '?')}
|
||
|
||
## 近 3 年业绩
|
||
{perf_block}
|
||
|
||
## 已提交的续聘材料包(已 OCR/解析)
|
||
|
||
### 文件清单
|
||
{checklist_bullets}
|
||
|
||
### 材料文本摘要
|
||
{materials_summary if materials_summary else '(材料文本尚未解析,将基于规则判断)'}
|
||
|
||
请基于以上真实材料内容给出续聘建议。"""
|
||
|
||
|
||
def _call_llm(
|
||
teacher_info: dict,
|
||
perf_records: list[sqlite3.Row],
|
||
materials_summary: str,
|
||
materials_files: list[str],
|
||
) -> dict | None:
|
||
"""调宿主 jiangchang_skill_core.llm_manager.call_llm_json。
|
||
返回 dict(含 suggestion / reason)或 None(不可用 / 失败)。
|
||
"""
|
||
try:
|
||
from jiangchang_skill_core.llm_manager import call_llm_json # type: ignore
|
||
except ImportError:
|
||
return None
|
||
|
||
user = _build_user_prompt(teacher_info, perf_records, materials_summary, materials_files)
|
||
try:
|
||
result = call_llm_json(system=SYSTEM_PROMPT, user=user)
|
||
except Exception as e:
|
||
logger.warning("llm_decision: LLM 调用失败降级: %s", e)
|
||
return None
|
||
|
||
if not isinstance(result, dict):
|
||
return None
|
||
sug = result.get("suggestion")
|
||
reason = result.get("reason", "")
|
||
if sug not in SUGGESTIONS:
|
||
logger.warning("llm_decision: LLM 返回非法 suggestion=%r", sug)
|
||
return None
|
||
return {"suggestion": sug, "reason": reason}
|
||
|
||
|
||
def decide_suggestion(
|
||
perf_records: list[sqlite3.Row],
|
||
materials_summary: str,
|
||
materials_files: list[str],
|
||
teacher_info: dict,
|
||
) -> Tuple[str, str, str]:
|
||
"""返回 (suggestion, reason, decision_source)。
|
||
|
||
decision_source:
|
||
"llm" - 来自宿主 LLM
|
||
"rules" - 规则版(已结合材料摘要)
|
||
"rules_no_materials" - 规则版(无材料数据)
|
||
"""
|
||
files = materials_files or list(DEFAULT_MATERIALS)
|
||
|
||
# 1) 优先 LLM
|
||
llm = _call_llm(teacher_info, perf_records, materials_summary, files)
|
||
if llm is not None:
|
||
logger.info(
|
||
"llm_decision: LLM 决策 %s (%s) materials=%d 字",
|
||
llm["suggestion"], llm["reason"], len(materials_summary),
|
||
)
|
||
return (llm["suggestion"], llm["reason"], "llm")
|
||
|
||
# 2) 降级规则版
|
||
sug, base_reason = task_generator.suggest(perf_records)
|
||
|
||
if materials_summary:
|
||
if files:
|
||
prefix = (
|
||
f"已结合材料包({len(files)} 份文件,"
|
||
f"共 {len(materials_summary)} 字内容)综合判断:"
|
||
)
|
||
else:
|
||
prefix = f"已结合材料包({len(materials_summary)} 字)综合判断:"
|
||
source = "rules"
|
||
elif files:
|
||
prefix = f"已结合材料包({len(files)} 份文件,待解析)综合判断:"
|
||
source = "rules"
|
||
else:
|
||
prefix = "(无材料数据,基于业绩规则判断)"
|
||
source = "rules_no_materials"
|
||
|
||
return (sug, prefix + base_reason, source) |