Files
alert-contract-expiring-hr/scripts/service/material_parser.py
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

143 lines
4.1 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.
"""材料包 PDF / Word 文档文本提取。
策略(自动降级):
- .pdf : pypdf 直接抽 → 若 < 200 字视为扫描件,pdf2image + pytesseract OCR 兜底
- .docx: python-docx 抽取段落
- .doc : 调 antiword 兜底(不在 win32 默认安装,仅 best-effort)
- 其他 : 返回 ("", "failed")
依赖均为可选:导入失败返回空串,让上层降级到规则版。
"""
from __future__ import annotations
from pathlib import Path
from util.logging_setup import get_skill_logger
logger = get_skill_logger()
# 单个材料文件 OCR 后保留的最大长度,避免 LLM context 爆栈
MAX_TEXT_PER_FILE = 8000
# LLM Prompt 里允许的总材料摘要上限
MAX_TOTAL_SUMMARY = 16000
def _pdf_pypdf(path: Path) -> str:
try:
from pypdf import PdfReader
except ImportError:
return ""
try:
reader = PdfReader(str(path))
out = []
for page in reader.pages:
t = page.extract_text() or ""
if t:
out.append(t)
return "\n".join(out)
except Exception as e:
logger.warning("pypdf 解析失败 %s: %s", path.name, e)
return ""
def _pdf_ocr(path: Path) -> str:
try:
from pdf2image import convert_from_path
import pytesseract
except ImportError:
logger.warning("OCR 依赖未安装(pdf2image / pytesseract)")
return ""
try:
images = convert_from_path(str(path), dpi=200)
return "\n".join(
pytesseract.image_to_string(img, lang="chi_sim+eng") for img in images
)
except Exception as e:
logger.warning("OCR 解析失败 %s: %s", path.name, e)
return ""
def _docx_text(path: Path) -> str:
try:
from docx import Document
except ImportError:
return ""
try:
doc = Document(str(path))
return "\n".join(p.text for p in doc.paragraphs if p.text)
except Exception as e:
logger.warning("docx 解析失败 %s: %s", path.name, e)
return ""
def _doc_antiword(path: Path) -> str:
"""legacy .doc:调系统 antiword 命令行。"""
import shutil, subprocess
if not shutil.which("antiword"):
return ""
try:
r = subprocess.run(
["antiword", str(path)], capture_output=True, text=True, timeout=30
)
return r.stdout if r.returncode == 0 else ""
except Exception as e:
logger.warning("antiword 解析失败 %s: %s", path.name, e)
return ""
def extract_text(path: Path) -> tuple[str, str]:
"""返回 (text, method)。method ∈ {'pypdf','docx','ocr','antiword','empty'}。
text 为空时 method='empty',调用方应据此判断是否降级到规则版。
"""
if not path.exists():
logger.warning("材料文件不存在: %s", path)
return ("", "empty")
suffix = path.suffix.lower()
text = ""
method = "empty"
if suffix == ".pdf":
text = _pdf_pypdf(path)
method = "pypdf" if text else "empty"
if len(text.strip()) < 200:
ocr_text = _pdf_ocr(path)
if ocr_text:
text = ocr_text
method = "ocr"
elif suffix == ".docx":
text = _docx_text(path)
method = "docx" if text else "empty"
elif suffix == ".doc":
text = _doc_antiword(path)
method = "antiword" if text else "empty"
if text:
text = text[:MAX_TEXT_PER_FILE]
return (text, method)
def summarize(texts: list[tuple[str, str, str]]) -> tuple[str, list[str]]:
"""把多份材料的 (file_name, text, method) 汇总为 LLM 摘要。
返回 (summary, methods)。summary 截断到 MAX_TOTAL_SUMMARY 字以内。
"""
if not texts:
return ("", [])
parts: list[str] = []
methods: list[str] = []
for file_name, text, method in texts:
if not text:
continue
methods.append(f"{file_name} ({method})")
parts.append(f"### {file_name}\n{text}")
joined = "\n\n".join(parts)
if len(joined) > MAX_TOTAL_SUMMARY:
joined = joined[:MAX_TOTAL_SUMMARY] + "\n...(以下材料内容过长已截断)"
return (joined, methods)