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

364 lines
12 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.
"""CLI 命令分发:argparse 子命令 → 调用 service 层。
v1.1.1:11 个子命令(在 9 个基础上新增)
- collect-from-platform:Playwright RPA 抓 SmartCampus HR 平台数据
- parse-materials:解析已下载的材料 PDF/Word → 文本
原有 9 个:
- health / version / config-path / init-db
- scan-expiring-contracts / generate-renewal-task
- dispatch-task-notice / mark-contract-renewed
- render-html
"""
from __future__ import annotations
import argparse
import json
import sys
from typing import Callable, List, Optional
from db.connection import get_connection, init_db
from db import repository as repo
from service import dispatcher, html_renderer, material_parser, platform_collector, scanner, task_generator
from util.constants import DEFAULT_THRESHOLDS, SKILL_VERSION
from util.logging_setup import get_skill_logger, setup_skill_logging
from util.runtime_paths import (
get_db_path,
get_dispatch_dir,
get_env_path,
get_output_dir,
)
logger = get_skill_logger()
# ---------- 命令实现 ----------
def cmd_health(_args: argparse.Namespace) -> int:
"""健康检查。"""
setup_skill_logging()
db_path = get_db_path()
db_exists = db_path.exists()
db_writable = False
error_msg = None
try:
conn = get_connection()
try:
conn.execute("SELECT 1").fetchone()
db_writable = True
finally:
conn.close()
except Exception as e:
error_msg = str(e)
output_dir = get_output_dir()
dispatch_dir = get_dispatch_dir()
checks = [
("python_version", f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", True),
("db_path", str(db_path), True),
("db_exists", str(db_exists), db_exists),
("db_writable", str(db_writable), db_writable),
("output_dir", str(output_dir), True),
("dispatch_dir", str(dispatch_dir), True),
]
if db_exists and db_writable:
try:
conn = get_connection()
try:
counts = {
"contracts": conn.execute("SELECT COUNT(*) AS n FROM contracts").fetchone()["n"],
"performance_records": conn.execute("SELECT COUNT(*) AS n FROM performance_records").fetchone()["n"],
"alerts": conn.execute("SELECT COUNT(*) AS n FROM alerts").fetchone()["n"],
"renewal_tasks": conn.execute("SELECT COUNT(*) AS n FROM renewal_tasks").fetchone()["n"],
"dispatch_log": conn.execute("SELECT COUNT(*) AS n FROM dispatch_log").fetchone()["n"],
}
finally:
conn.close()
checks.append(("seed_loaded", f"contracts={counts['contracts']} perf={counts['performance_records']}", counts["contracts"] > 0))
except Exception as e:
checks.append(("seed_check", f"ERROR: {e}", False))
all_ok = all(c[2] for c in checks)
for name, val, ok in checks:
marker = "OK " if ok else "FAIL"
print(f"[{marker}] {name}: {val}")
if error_msg:
print(f"[FAIL] error: {error_msg}")
if all_ok:
print("OK: health check passed")
return 0
print("ERROR: health check failed")
return 1
def cmd_version(_args: argparse.Namespace) -> int:
print(f"{SKILL_VERSION}")
return 0
def cmd_config_path(_args: argparse.Namespace) -> int:
print(get_env_path())
return 0
def cmd_init_db(_args: argparse.Namespace) -> int:
counts = init_db()
print(f"OK: init-db complete")
for k, v in counts.items():
print(f" {k}: {v} rows")
return 0
def cmd_scan(args: argparse.Namespace) -> int:
thresholds = _parse_thresholds(args.thresholds)
conn = get_connection()
try:
result = scanner.scan(conn, thresholds=thresholds)
finally:
conn.close()
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
def cmd_generate_task(args: argparse.Namespace) -> int:
contract_id = args.contract_id
task_id = args.task_id
conn = get_connection()
try:
result = task_generator.regenerate_task_for_contract_id(conn, contract_id, task_id)
conn.commit()
except ValueError as e:
print(f"ERROR: {e}")
return 2
finally:
conn.close()
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
def cmd_dispatch(args: argparse.Namespace) -> int:
conn = get_connection()
try:
result = dispatcher.dispatch(conn, args.unit_id, args.month or "")
finally:
conn.close()
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
def cmd_mark_renewed(args: argparse.Namespace) -> int:
conn = get_connection()
try:
from db import repository as repo
contract = repo.get_contract(conn, args.contract_id)
if contract is None:
print(f"ERROR: contract_id={args.contract_id!r} 不存在")
return 2
with conn:
n = repo.update_contract_status(conn, args.contract_id, "已续签", args.material_id)
closed = repo.close_open_alerts_for_contract(conn, args.contract_id)
print(json.dumps({
"contract_id": args.contract_id,
"status": "已续签",
"material_id": args.material_id,
"updated_rows": n,
"closed_alerts": closed,
}, ensure_ascii=False, indent=2))
finally:
conn.close()
return 0
def cmd_render_html(args: argparse.Namespace) -> int:
conn = get_connection()
try:
output_path = html_renderer.render_task_html(conn, args.task_id)
except ValueError as e:
print(f"ERROR: {e}")
return 2
except FileNotFoundError as e:
print(f"ERROR: {e}")
return 3
finally:
conn.close()
print(json.dumps({
"task_id": args.task_id,
"output_path": str(output_path),
"size_bytes": output_path.stat().st_size,
}, ensure_ascii=False, indent=2))
return 0
def cmd_collect_platform(args: argparse.Namespace) -> int:
"""Playwright RPA 抓 SmartCampus HR 平台数据 → 落库。
首次需用户手工登录保存 cookie(默认 headless=False 便于交互);
后续可用 headless=True 复用 profile。
退出码:
0 = 成功(即使抓到 0 行也返回 0,错误信息在 output["error"])
1 = 通用异常
"""
try:
result = platform_collector.collect_all(
limit=args.limit,
headless=not args.show_browser,
)
except Exception as e:
print(json.dumps({"error": "exception", "detail": str(e)},
ensure_ascii=False, indent=2))
return 1
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
def cmd_parse_materials(args: argparse.Namespace) -> int:
"""对已落库的材料 PDF/Word 跑文本提取(pypdf / python-docx / OCR)。
--contract-id C001: 仅处理该合同
不传:处理所有 parse_method='pending' 或 extracted_text IS NULL 的材料
"""
conn = get_connection()
try:
if args.contract_id:
rows = repo.list_materials_for_contract(conn, args.contract_id)
else:
rows = repo.list_pending_materials(conn)
results: list[dict] = []
for r in rows:
from pathlib import Path
fp = Path(r["file_path"])
text, method = material_parser.extract_text(fp)
if text:
repo.insert_material(
conn,
r["contract_id"], r["file_name"], r["file_path"],
text, method,
)
else:
repo.insert_material(
conn,
r["contract_id"], r["file_name"], r["file_path"],
"", "failed",
)
results.append({
"contract_id": r["contract_id"],
"file_name": r["file_name"],
"parse_method": method,
"text_length": len(text),
})
conn.commit()
print(json.dumps({
"processed": len(results),
"results": results,
}, ensure_ascii=False, indent=2))
return 0
finally:
conn.close()
# ---------- argparse ----------
def _parse_thresholds(s: str) -> tuple[int, int, int]:
parts = [int(p.strip()) for p in s.split(",")]
if len(parts) != 3 or sorted(parts, reverse=True) != parts:
raise argparse.ArgumentTypeError(
f"thresholds 必须是 3 个整数(黄,橙,红)且从大到小,例如 90,60,30;收到 {s!r}"
)
return (parts[0], parts[1], parts[2])
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="main.py",
description=f"合同到期智能预警与续聘辅助 v{SKILL_VERSION}",
)
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("health", help="健康检查(Python / DB / 目录)").set_defaults(handler=cmd_health)
sub.add_parser("version", help="查看版本").set_defaults(handler=cmd_version)
sub.add_parser("config-path", help="查看 .env 路径").set_defaults(handler=cmd_config_path)
sub.add_parser("init-db", help="建表 + 加载样例数据").set_defaults(handler=cmd_init_db)
sp = sub.add_parser(
"scan-expiring-contracts", help="扫描即将到期合同(async/cron)",
)
sp.add_argument(
"--thresholds", default="90,60,30",
help="三级预警阈值(黄,橙,红)从大到小,默认 90,60,30",
)
sp.set_defaults(handler=cmd_scan)
sp = sub.add_parser(
"generate-renewal-task", help="为指定合同生成续聘任务书",
)
sp.add_argument("--contract-id", required=True, help="合同 ID")
sp.add_argument("--task-id", default=None, help="可选:指定 task_id(默认自动生成)")
sp.set_defaults(handler=cmd_generate_task)
sp = sub.add_parser(
"dispatch-task-notice", help="按单位/月份派发续聘通知(dry-run)",
)
sp.add_argument("--unit-id", required=True, help="二级单位 ID")
sp.add_argument("--month", default="", help="月份 YYYY-MM(留空 = 全部)")
sp.set_defaults(handler=cmd_dispatch)
sp = sub.add_parser(
"mark-contract-renewed", help="标记合同已续签",
)
sp.add_argument("--contract-id", required=True, help="合同 ID")
sp.add_argument("--material-id", required=True, help="续签材料 ID")
sp.set_defaults(handler=cmd_mark_renewed)
sp = sub.add_parser(
"render-html", help="渲染任务书 HTML",
)
sp.add_argument("--task-id", required=True, help="任务 ID")
sp.set_defaults(handler=cmd_render_html)
sp = sub.add_parser(
"collect-from-platform",
help="Playwright RPA 抓 SmartCampus HR 平台(合同表 + 材料包)",
)
sp.add_argument("--limit", type=int, default=0,
help="限制抓取行数(0=全量)")
sp.add_argument("--show-browser", action="store_true",
help="显示浏览器(首次手工登录用,默认 headless)")
sp.set_defaults(handler=cmd_collect_platform)
sp = sub.add_parser(
"parse-materials", help="解析已下载的 PDF/Word 材料 → 文本",
)
sp.add_argument("--contract-id", default="",
help="仅解析该合同(留空=处理所有 pending)")
sp.set_defaults(handler=cmd_parse_materials)
return p
def main(argv: Optional[List[str]] = None) -> int:
argv = argv if argv is not None else sys.argv[1:]
if not argv:
print(f"合同到期智能预警与续聘辅助 v{SKILL_VERSION}")
print("Commands: health, version, config-path, init-db, "
"scan-expiring-contracts, generate-renewal-task, "
"dispatch-task-notice, mark-contract-renewed, render-html, "
"collect-from-platform, parse-materials")
return 1
setup_skill_logging()
parser = build_parser()
args = parser.parse_args(argv)
handler: Callable[[argparse.Namespace], int] = args.handler
return handler(args)