Files
taojianmin a3b4092a30 v1.1.1: 升级 AI 续聘建议为 LLM + Playwright RPA 采集 SmartCampus 平台材料
新增:

- 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>
2026-09-14 15:01:24 +08:00

153 lines
5.2 KiB
Python
Raw Permalink 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 续聘建议统一入口:优先 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)