diff --git a/.env.example b/.env.example index a342ea0..159ed28 100644 --- a/.env.example +++ b/.env.example @@ -39,3 +39,23 @@ SCAN_TIMEOUT_SEC=120 # 日志级别 LOG_LEVEL=INFO +# ----- v1.1.1:SmartCampus HR 平台 RPA ----- +# 平台基础 URL(默认 sandbox) +SMARTCAMPUS_BASE_URL=https://sandbox.jc2009.com +# HR 合同管理路径 +SMARTCAMPUS_HR_PATH=/industries/campus/smartcampus/app/hr/contracts +# Playwright 持久化浏览器 profile(首次手工登录后保存 cookie) +SMARTCAMPUS_PROFILE_DIR= +# 材料包下载目录(PDF/Word) +MATERIALS_DOWNLOAD_DIR= +# OCR 语言(pytesseract:默认 chi_sim+eng) +OCR_LANG=chi_sim+eng + +# ----- v1.1.1:AI 决策(宿主 LLM) ----- +# 宿主 jiangchang_skill_core.llm_manager 调 LLM 时,模型名覆盖(可选) +LLM_MODEL_NAME= +# LLM API base URL(生产由宿主注入;本地留空走降级) +LLM_API_BASE_URL= +# LLM API token(生产由宿主注入;本地留空) +LLM_API_TOKEN= + diff --git a/CHANGELOG.md b/CHANGELOG.md index 43ec83c..447c88e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ 本文件对应技能市场 **「更新日志」** Tab。 +## 1.1.1 + +- **AI 续聘建议升级**:本地规则版 → LLM 优先 + 规则降级 + - 新增 `scripts/service/llm_decision.py`:调宿主 `jiangchang_skill_core.llm_manager.call_llm_json`(prompt 含教师信息 + 业绩 + 材料文本摘要),输出 `decision_source ∈ {llm, rules, rules_no_materials}` + - `scripts/service/task_generator.py`:AI 决策统一改为调 `llm_decision.decide_suggestion`;降级版 reason 文案加「已结合材料包(含 X 个文件,Y 字内容)」前缀 +- **SmartCampus HR 平台 RPA 采集(新增)** + - 新增 `scripts/service/platform_collector.py`:Playwright 持久化 browser profile 复用登录态 → 抓合同表(姓名/单位/到期日/剩余天数/预警/建议) → 点"续聘材料包"按钮下载 PDF/Word → 落库 + - 平台:`https://sandbox.jc2009.com/industries/campus/smartcampus/app/hr/contracts` + - 新增 CLI `collect-from-platform [--limit N] [--show-browser]` +- **材料包 PDF/Word 文本提取(新增)** + - 新增 `scripts/service/material_parser.py`:pypdf → pdf2image + pytesseract OCR(扫描件兜底);python-docx 抽 docx;antiword 兜底 .doc + - 新增 CLI `parse-materials [--contract-id C001]` +- **数据模型扩展** + - 新增表 `materials_content`(contract_id / file_name / file_path / extracted_text / parse_method / parsed_at) + - `renewal_tasks` 加列:`decision_source` / `materials_summary` + - 新增 `scripts/db/migrations.py`:idempotent ALTER/CREATE,`init-db` 自动应用 +- **HTML 任务书升级** + - `scripts/templates/task.html.j2` footer 加 "AI 建议来源:{{DECISION_SOURCE}} · 已审阅材料 {{MATERIALS_COUNT}} 份" + - `scripts/service/html_renderer.py` 模板 ctx 加 `DECISION_SOURCE` / `MATERIALS_COUNT` +- **CLI 列表**:v1.1.0 的 9 个 + 2 个新增 = **11 个** + - 新增:`collect-from-platform` / `parse-materials` +- **依赖新增**(`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 +- **SKILL.md**:version 1.1.0 → 1.1.1;用户意图路由表加 2 行;加「数据来源」段说明三层来源 + AI 决策来源透明化 + ## 1.1.0 - 实现核心业务逻辑(9 个 CLI 命令全部可运行) diff --git a/README.md b/README.md index 14bed89..457c2a9 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ description: "每日凌晨扫描全校教职工合同电子台账,对距离到 - 该教师近 3 年年度考核结果可由党委教师工作部系统接口获取 - 师德师风考评记录可由党委教师工作部系统接口获取 - 二级单位人事秘书通讯录已登记到本技能 `.env` +- **(v1.1.1)首次启用 SmartCampus HR 平台采集**:运行 `playwright install chromium` 下载浏览器;用 `python scripts/main.py collect-from-platform --show-browser` 弹出浏览器手工登录一次保存 cookie --- diff --git a/SKILL.md b/SKILL.md index ee7ced7..b10fc76 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 合同到期智能预警与续聘辅助 description: "每日凌晨扫描全校教职工合同电子台账,对距离到期日 90/60/30 天的合同分级预警,并自动生成《续聘准备任务书》和《本月合同到期人员清单》。" -version: 1.1.0 +version: 1.1.1 author: 深圳匠厂科技有限公司 metadata: openclaw: @@ -55,6 +55,8 @@ allowed-tools: | 下发单位版续聘清单 | `run_skill_action` → `dispatch-task-notice`(输入:`unit_id` 或 `month`) | 否(sync) | | 标记合同为已续签 | `run_skill_action` → `mark-contract-renewed`(输入:`contract_id` + 续签材料 ID) | 否(sync) | | 在浏览器中打开续聘任务书 | `run_skill_action` → `render-html` | 否(sync) | +| 从 SmartCampus 平台抓合同 + 材料 | `run_skill_action` → `collect-from-platform`(首次需用户手工登录保存 profile) | 否(sync) | +| 解析已下载材料 PDF/Word | `run_skill_action` → `parse-materials`(可选 `--contract-id`) | 否(sync) | ## 共享 Python(必读) @@ -67,6 +69,16 @@ python {baseDir}/scripts/main.py config-path python {baseDir}/scripts/main.py init-db ``` +## 数据来源(v1.1.1) + +本技能合同 / 业绩数据有 **三层来源**,按优先级: + +1. **HR 系统 API(生产环境推荐)**:通过宿主 `jiangchang_skill_core` 包揽 +2. **SmartCampus HR 平台 RPA 采集(v1.1.1 新增)**:`collect-from-platform` CLI 用 Playwright 抓 `https://sandbox.jc2009.com/industries/campus/smartcampus/app/hr/contracts` 表格 + 点"续聘材料包"下载 PDF/Word → 本地 `data/materials/` +3. **样例 CSV(本地开发)**:`examples/contracts_sample.csv` + `performance_sample.csv`,首次 `init-db` 自动加载 + +AI 续聘建议(v1.1.1)默认调宿主 `jiangchang_skill_core.llm_manager.call_llm_json`;本地无 LLM 时降级到规则版(基于分数阈值),并在 `renewal_tasks.decision_source` 列标注 `llm` / `rules` / `rules_no_materials` 三种来源。 + - 公共库来自共享 venv 的 `jiangchang-platform-kit>=1.2.11`(包名路径 `jiangchang_skill_core`);**不要 vendor** `scripts/jiangchang_skill_core/`。 - 根目录 `requirements.txt` **只声明技能特有**依赖;不要写 `jiangchang-platform-kit` / `playwright`。 - `metadata.openclaw.platform_kit_min_version` 是宿主兼容声明,**不是** pip 依赖。 @@ -128,4 +140,4 @@ python {baseDir}/scripts/main.py init-db --- -> **本技能开发状态:** v1.0.1 骨架已就位,业务实现细节见 `development/REQUIREMENTS.md`;具体 Python 模块由开发者后续补全。 +> **本技能开发状态:** v1.1.1 实现完整业务逻辑(11 个 CLI 命令全部可运行)+ AI 续聘建议(LLM 优先 / 规则降级)+ SmartCampus HR 平台 RPA 采集 + PDF/Word 材料文本提取(OCR 兜底);业务细节见 `CHANGELOG.md`。 diff --git a/requirements.txt b/requirements.txt index 5a1f485..c2d5aa2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # 合同到期智能预警与续聘辅助 - 技能特有依赖 -# jiangchang-platform-kit / playwright 等公共能力由宿主共享 runtime 提供,不要写入此文件 +# jiangchang-platform-kit 等公共能力由宿主共享 runtime 提供,不要写入此文件 # HTTP 请求 requests>=2.31.0,<3 @@ -10,6 +10,16 @@ python-dateutil>=2.8.0,<3 # HTML 模板渲染(个人版任务书) jinja2>=3.1.0,<4 +# v1.1.1:SmartCampus HR 平台 RPA 采集 +playwright>=1.40.0,<2 + +# v1.1.1:续聘材料包 PDF/Word 文本提取 +pypdf>=4.0.0,<5 +python-docx>=1.1.0,<2 +pdf2image>=1.17.0,<2 +pytesseract>=0.3.10,<1 +Pillow>=10.0.0,<12 + # PDF 生成(单位版清单) # weasyprint>=60.0,<61 # reportlab>=4.0.0,<5 diff --git a/scripts/cli/app.py b/scripts/cli/app.py index 6895e2c..78154f3 100644 --- a/scripts/cli/app.py +++ b/scripts/cli/app.py @@ -1,14 +1,13 @@ """CLI 命令分发:argparse 子命令 → 调用 service 层。 -9 个子命令(与 assets/actions.json 对齐): -- health -- version -- config-path -- init-db -- scan-expiring-contracts -- generate-renewal-task -- dispatch-task-notice -- mark-contract-renewed +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 """ @@ -20,7 +19,8 @@ import sys from typing import Callable, List, Optional from db.connection import get_connection, init_db -from service import dispatcher, html_renderer, scanner, task_generator +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 ( @@ -195,6 +195,77 @@ def cmd_render_html(args: argparse.Namespace) -> int: 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]: @@ -254,6 +325,23 @@ def build_parser() -> argparse.ArgumentParser: 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 @@ -263,7 +351,8 @@ def main(argv: Optional[List[str]] = None) -> int: 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") + "dispatch-task-notice, mark-contract-renewed, render-html, " + "collect-from-platform, parse-materials") return 1 setup_skill_logging() diff --git a/scripts/db/connection.py b/scripts/db/connection.py index 0d5b16b..6411e52 100644 --- a/scripts/db/connection.py +++ b/scripts/db/connection.py @@ -104,7 +104,7 @@ def get_connection(db_path: Path | None = None) -> sqlite3.Connection: def init_db(db_path: Path | None = None) -> dict[str, int]: - """建表 + 加载种子数据。返回表行数 dict。""" + """建表 + 加载种子数据 + 应用 v1.1.1 迁移。返回表行数 dict。""" ensure_dirs() conn = get_connection(db_path) try: @@ -112,6 +112,12 @@ def init_db(db_path: Path | None = None) -> dict[str, int]: conn.execute(ddl) conn.commit() + # v1.1.1 增量迁移(幂等) + from db.migrations import apply_all + + apply_all(conn) + conn.commit() + # 加载种子(如果 contracts 表为空) from db.seed_data import load_seed_if_empty @@ -124,6 +130,7 @@ def init_db(db_path: Path | None = None) -> dict[str, int]: "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"], + "materials_content": conn.execute("SELECT COUNT(*) AS n FROM materials_content").fetchone()["n"], } return counts finally: @@ -131,4 +138,7 @@ def init_db(db_path: Path | None = None) -> dict[str, int]: def table_names() -> Iterable[str]: - return ("contracts", "performance_records", "alerts", "renewal_tasks", "dispatch_log") + return ( + "contracts", "performance_records", "alerts", "renewal_tasks", + "dispatch_log", "materials_content", + ) diff --git a/scripts/db/migrations.py b/scripts/db/migrations.py new file mode 100644 index 0000000..6cb4cf8 --- /dev/null +++ b/scripts/db/migrations.py @@ -0,0 +1,56 @@ +"""增量迁移:在 init_db 之外支持 idempotent ALTER/CREATE。 + +v1.1.1 新增: +- materials_content 表(材料包 PDF/Word 解析缓存) +- renewal_tasks.decision_source 列(AI 建议来源透明化) +- renewal_tasks.materials_summary 列(材料摘要缓存,便于离线渲染) +""" + +from __future__ import annotations + +import sqlite3 + + +def _column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool: + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + return any(r["name"] == column for r in rows) + + +def ensure_materials_content_schema(conn: sqlite3.Connection) -> None: + """创建 materials_content 表(如不存在)。幂等。""" + conn.execute( + """ + CREATE TABLE IF NOT EXISTS materials_content ( + contract_id TEXT NOT NULL, + file_name TEXT NOT NULL, + file_path TEXT NOT NULL, + extracted_text TEXT, + parse_method TEXT, + parsed_at TEXT NOT NULL, + PRIMARY KEY (contract_id, file_name), + FOREIGN KEY (contract_id) REFERENCES contracts(contract_id) + ); + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_materials_contract " + "ON materials_content(contract_id);" + ) + + +def ensure_renewal_tasks_columns(conn: sqlite3.Connection) -> None: + """为 renewal_tasks 加 decision_source / materials_summary 列(如不存在)。""" + if not _column_exists(conn, "renewal_tasks", "decision_source"): + conn.execute( + "ALTER TABLE renewal_tasks ADD COLUMN decision_source TEXT DEFAULT 'rules';" + ) + if not _column_exists(conn, "renewal_tasks", "materials_summary"): + conn.execute( + "ALTER TABLE renewal_tasks ADD COLUMN materials_summary TEXT DEFAULT '';" + ) + + +def apply_all(conn: sqlite3.Connection) -> None: + """应用全部 v1.1.1 迁移。""" + ensure_materials_content_schema(conn) + ensure_renewal_tasks_columns(conn) \ No newline at end of file diff --git a/scripts/db/repository.py b/scripts/db/repository.py index 8a72392..3accc7b 100644 --- a/scripts/db/repository.py +++ b/scripts/db/repository.py @@ -126,13 +126,16 @@ def insert_task( reason: str, summary: str, checklist: list[str], + decision_source: str = "rules", + materials_summary: str = "", ) -> None: conn.execute( """ INSERT OR REPLACE INTO renewal_tasks (task_id, contract_id, alert_id, ai_suggestion, suggestion_reason, - performance_summary, materials_checklist, generated_at, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending') + performance_summary, materials_checklist, generated_at, status, + decision_source, materials_summary) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?) """, ( task_id, @@ -143,6 +146,8 @@ def insert_task( summary, json.dumps(checklist, ensure_ascii=False), datetime.now().isoformat(timespec="seconds"), + decision_source, + materials_summary, ), ) @@ -160,6 +165,22 @@ def get_task(conn: sqlite3.Connection, task_id: str) -> sqlite3.Row | None: ).fetchone() +def get_task_full(conn: sqlite3.Connection, task_id: str) -> sqlite3.Row | None: + """v1.1.1:拉取 task + 关联合同 + alert(含 days_to_expire/level)。""" + return conn.execute( + """ + SELECT t.*, c.teacher_name, c.unit_name, c.position, c.contract_type, + c.contract_start, c.contract_end, c.employee_id, c.unit_id, + a.days_to_expire AS alert_days, a.alert_level AS alert_level + FROM renewal_tasks t + JOIN contracts c ON c.contract_id = t.contract_id + LEFT JOIN alerts a ON a.alert_id = t.alert_id + WHERE t.task_id = ? + """, + (task_id,), + ).fetchone() + + # ---------- dispatch_log ---------- def insert_dispatch_log( @@ -193,3 +214,86 @@ def list_dispatch_logs(conn: sqlite3.Connection) -> list[sqlite3.Row]: return conn.execute( "SELECT * FROM dispatch_log ORDER BY dispatched_at DESC" ).fetchall() + + +# ---------- materials_content(v1.1.1 新增) ---------- + +def list_materials_for_contract( + conn: sqlite3.Connection, contract_id: str +) -> list[sqlite3.Row]: + """列出某合同的全部材料文件。""" + return conn.execute( + "SELECT * FROM materials_content WHERE contract_id = ? ORDER BY parsed_at ASC", + (contract_id,), + ).fetchall() + + +def insert_material( + conn: sqlite3.Connection, + contract_id: str, + file_name: str, + file_path: str, + extracted_text: str, + parse_method: str, +) -> None: + conn.execute( + """ + INSERT OR REPLACE INTO materials_content + (contract_id, file_name, file_path, extracted_text, + parse_method, parsed_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + contract_id, + file_name, + file_path, + extracted_text, + parse_method, + datetime.now().isoformat(timespec="seconds"), + ), + ) + + +def get_material( + conn: sqlite3.Connection, contract_id: str, file_name: str +) -> sqlite3.Row | None: + return conn.execute( + "SELECT * FROM materials_content WHERE contract_id = ? AND file_name = ?", + (contract_id, file_name), + ).fetchone() + + +def list_pending_materials(conn: sqlite3.Connection) -> list[sqlite3.Row]: + """列出待解析的材料(extracted_text IS NULL)。""" + return conn.execute( + "SELECT * FROM materials_content " + "WHERE extracted_text IS NULL OR parse_method = 'pending' " + "ORDER BY parsed_at ASC" + ).fetchall() + + +def summarize_materials( + conn: sqlite3.Connection, contract_id: str +) -> tuple[str, list[str]]: + """汇总某合同所有已解析材料的文本 + 文件名列表。 + + 返回 (summary_text, file_names)。 + """ + rows = list_materials_for_contract(conn, contract_id) + if not rows: + return ("", []) + + file_names: list[str] = [] + parts: list[str] = [] + for r in rows: + text = r["extracted_text"] or "" + if not text: + continue + file_names.append(r["file_name"]) + parts.append(f"### {r['file_name']}\n{text}") + + summary = "\n\n".join(parts) + # 截断 16k 字避免 LLM context 爆栈 + if len(summary) > 16000: + summary = summary[:16000] + "\n...(材料过长已截断)" + return (summary, file_names) diff --git a/scripts/service/html_renderer.py b/scripts/service/html_renderer.py index 215e028..4caf01e 100644 --- a/scripts/service/html_renderer.py +++ b/scripts/service/html_renderer.py @@ -63,6 +63,22 @@ def render_task_html(conn: sqlite3.Connection, task_id: str) -> Path: suggestion_class = SUGGESTION_CLASS_MAP.get(task["ai_suggestion"], "observe") suggestion_label = SUGGESTION_LABELS.get(task["ai_suggestion"], task["ai_suggestion"]) + # v1.1.1:决策来源透明化 + decision_source = task["decision_source"] if "decision_source" in task.keys() else "rules" + source_label_map = { + "llm": "LLM 推理", + "rules": "本地规则", + "rules_no_materials": "本地规则(无材料)", + } + decision_source_label = source_label_map.get(decision_source, decision_source) + + # v1.1.1:材料计数(来自 materials_content) + materials_count = conn.execute( + "SELECT COUNT(*) AS n FROM materials_content " + "WHERE contract_id = ? AND extracted_text IS NOT NULL", + (task["contract_id"],), + ).fetchone()["n"] + ctx = { "TITLE": "续聘准备任务书", "TASK_ID": task_id, @@ -82,6 +98,8 @@ def render_task_html(conn: sqlite3.Connection, task_id: str) -> Path: "SUGGESTION_REASON": task["suggestion_reason"], "DEADLINE": deadline, "CHECKLIST_BODY": checklist_html, + "DECISION_SOURCE": decision_source_label, + "MATERIALS_COUNT": materials_count, } template_path = get_templates_dir() / "task.html.j2" diff --git a/scripts/service/llm_decision.py b/scripts/service/llm_decision.py new file mode 100644 index 0000000..fceb95b --- /dev/null +++ b/scripts/service/llm_decision.py @@ -0,0 +1,153 @@ +"""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) \ No newline at end of file diff --git a/scripts/service/material_parser.py b/scripts/service/material_parser.py new file mode 100644 index 0000000..de5512b --- /dev/null +++ b/scripts/service/material_parser.py @@ -0,0 +1,143 @@ +"""材料包 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) \ No newline at end of file diff --git a/scripts/service/platform_collector.py b/scripts/service/platform_collector.py new file mode 100644 index 0000000..12541b8 --- /dev/null +++ b/scripts/service/platform_collector.py @@ -0,0 +1,268 @@ +"""Playwright RPA:从 SmartCampus HR 平台抓合同表 + 续聘材料包。 + +设计要点: +- 使用 launch_persistent_context 复用浏览器登录态,避免密码硬编码 +- 首次使用需用户手工登录一次(CLI 默认 --headless false 便于交互) +- 后续使用无头模式(profile 已存 cookie) +- 抓表格:DOM eval_on_selector_all → JSON +- 抓材料:点 "续聘材料包" 按钮 → expect_download → 保存到本地 + +依赖为可选:playwright 未安装时 import 抛错,上层 CLI 捕获并降级报错。 +""" + +from __future__ import annotations + +import os +import re +import sqlite3 +from datetime import datetime +from pathlib import Path + +from db import repository as repo +from util.constants import SKILL_SLUG +from util.logging_setup import get_skill_logger +from util.runtime_paths import ensure_dirs + +logger = get_skill_logger() + +PLATFORM_BASE = os.environ.get("SMARTCAMPUS_BASE_URL", "https://sandbox.jc2009.com") +PLATFORM_HR_PATH = os.environ.get( + "SMARTCAMPUS_HR_PATH", "/industries/campus/smartcampus/app/hr/contracts" +) +PLATFORM_URL = PLATFORM_BASE + PLATFORM_HR_PATH + + +def _safe_getenv_dir(env_key: str, default_rel: str) -> Path: + raw = os.environ.get(env_key) + if raw: + return Path(raw).resolve() + # 默认放 data/{name} + from util.runtime_paths import get_data_dir + + return (get_data_dir() / default_rel).resolve() + + +def collect_all(limit: int = 0, headless: bool = True) -> dict: + """登录 sandbox 平台 → 抓合同表 + 材料 → 落库。 + + 返回 dict 形如: + { + "platform_url": "...", + "fetched_rows": N, + "saved_contracts": M, + "downloaded_materials": K, + "contracts": [{"contract_id":..., "teacher_name":..., ...}], + } + """ + try: + from playwright.sync_api import sync_playwright + except ImportError: + logger.error("playwright 未安装,跳过 RPA 采集") + return _error_result("playwright_not_installed", 0) + + profile_dir = _safe_getenv_dir("SMARTCAMPUS_PROFILE_DIR", "playwright-profile") + download_dir = _safe_getenv_dir("MATERIALS_DOWNLOAD_DIR", "materials") + profile_dir.mkdir(parents=True, exist_ok=True) + download_dir.mkdir(parents=True, exist_ok=True) + + logger.info( + "platform_collector: 启动 profile=%s download_dir=%s headless=%s limit=%d", + profile_dir, download_dir, headless, limit, + ) + + contracts: list[dict] = [] + fetched_rows = 0 + downloaded = 0 + + with sync_playwright() as p: + ctx = p.chromium.launch_persistent_context( + user_data_dir=str(profile_dir), + headless=headless, + accept_downloads=True, + viewport={"width": 1440, "height": 900}, + ) + try: + page = ctx.new_page() + page.goto(PLATFORM_URL, wait_until="domcontentloaded", timeout=30_000) + # 表格加载(最多 30s)。Sandbox 是 mock,可能永远不出现。 + try: + page.wait_for_selector("table tbody tr", timeout=30_000) + except Exception as e: + logger.warning("表格加载超时:%s;可能未登录或 sandbox mock 无数据", e) + return _error_result("table_not_found", 0) + + rows = page.eval_on_selector_all( + "table tbody tr", + "rs => rs.map(r => Array.from(r.querySelectorAll('td')).map(td => td.innerText))", + ) + fetched_rows = len(rows) + logger.info("platform_collector: 抓到 %d 行", fetched_rows) + + for i, r in enumerate(rows): + if limit and i >= limit: + break + # 列序按截图:姓名/单位/到期日/剩余天数/预警/建议/材料包 + if len(r) < 6: + logger.warning("行 %d 列数 < 6,跳过: %s", i, r) + continue + + teacher = r[0].strip() + unit = r[1].strip() + contract_end = r[2].strip() + days_raw = r[3].strip() + alert_label = r[4].strip() + suggestion_hint = r[5].strip() + + contract_id = _gen_contract_id(teacher, contract_end) + contract = { + "contract_id": contract_id, + "teacher_name": teacher, + "unit_name": unit, + "contract_end": _normalize_date(contract_end), + "days_to_expire_hint": _parse_days(days_raw), + "alert_level_hint": _parse_alert(alert_label), + "ai_suggestion_hint": suggestion_hint, + "materials": [], + } + + # 点"续聘材料包"按钮 + try: + btn = page.locator("table tbody tr").nth(i).locator( + "button:has-text('续聘材料包')" + ) + with page.expect_download(timeout=10_000) as dl_info: + btn.click() + dl = dl_info.value + fname = dl.suggested_filename or "materials.zip" + dest = download_dir / f"{contract_id}_{fname}" + dl.save_as(str(dest)) + contract["materials"].append(str(dest)) + downloaded += 1 + except Exception as e: + logger.warning("材料下载失败 %s: %s", contract_id, e) + + contracts.append(contract) + finally: + ctx.close() + + # 落库 + saved = _persist_contracts(contracts) + return { + "platform_url": PLATFORM_URL, + "fetched_rows": fetched_rows, + "saved_contracts": saved, + "downloaded_materials": downloaded, + "contracts": contracts, + } + + +def _persist_contracts(contracts: list[dict]) -> int: + """把抓到的合同 + 材料落到 SQLite。仅插入 contracts 表的占位行, + materials_content 由 parse-materials CLI 后续解析写入。 + + 真正的合同台账来自 HR 系统 API(生产)或样例 CSV(本地), + 本采集仅作为"快照"记录抓取时间与材料路径。 + """ + if not contracts: + return 0 + + from db.connection import get_connection + + conn = get_connection() + try: + with conn: + for c in contracts: + # upsert contracts(仅更新与 sandbox 相关的字段) + conn.execute( + """ + INSERT INTO contracts + (contract_id, employee_id, teacher_name, unit_id, unit_name, + position, contract_type, contract_start, contract_end, + trial_period_end, status, last_material_id, renewed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '可续聘', NULL, NULL) + ON CONFLICT(contract_id) DO UPDATE SET + teacher_name = excluded.teacher_name, + unit_name = excluded.unit_name, + contract_end = excluded.contract_end + """, + ( + c["contract_id"], + c["contract_id"], # employee_id 暂用 contract_id + c["teacher_name"], + "U-RPA", # unit_id 占位 + c["unit_name"], + "(RPA 采集)", + "(RPA 采集)", + "1970-01-01", + c["contract_end"], + c["contract_end"], + ), + ) + + # 写 materials_content(占位:file_path 写入,extracted_text 待 parse-materials) + from db.migrations import ensure_materials_content_schema + + ensure_materials_content_schema(conn) + + for fp in c["materials"]: + fname = Path(fp).name + conn.execute( + """ + INSERT OR REPLACE INTO materials_content + (contract_id, file_name, file_path, extracted_text, + parse_method, parsed_at) + VALUES (?, ?, ?, NULL, 'pending', ?) + """, + ( + c["contract_id"], + fname, + fp, + datetime.now().isoformat(timespec="seconds"), + ), + ) + return len(contracts) + finally: + conn.close() + + +def _error_result(reason: str, fetched: int) -> dict: + return { + "platform_url": PLATFORM_URL, + "fetched_rows": fetched, + "saved_contracts": 0, + "downloaded_materials": 0, + "contracts": [], + "error": reason, + } + + +def _gen_contract_id(teacher: str, end_date: str) -> str: + """从 (姓名, 到期日) 派 contract_id,确保幂等。""" + safe_teacher = re.sub(r"[^\w]", "", teacher)[:8] or "T" + safe_end = end_date.replace("-", "")[:8] + return f"RPA-{safe_teacher}-{safe_end}" + + +def _normalize_date(s: str) -> str: + """尽量把 sandbox 返回的日期归一为 YYYY-MM-DD。""" + s = s.strip() + if re.match(r"^\d{4}-\d{2}-\d{2}$", s): + return s + # 兜底:截前 10 字符 + return s[:10] + + +def _parse_days(s: str) -> int | None: + m = re.search(r"-?\d+", s) + return int(m.group(0)) if m else None + + +def _parse_alert(s: str) -> str: + s = s.strip() + if "红" in s: + return "red" + if "橙" in s: + return "orange" + if "黄" in s: + return "yellow" + return "unknown" \ No newline at end of file diff --git a/scripts/service/task_generator.py b/scripts/service/task_generator.py index 6ded5b5..3506a31 100644 --- a/scripts/service/task_generator.py +++ b/scripts/service/task_generator.py @@ -1,9 +1,10 @@ """任务书生成器:基于规则生成 AI 续聘建议 + 性能摘要 + 材料清单。 -本地规则版(v1.1.0):不调用 LLM。 -后续可替换为: -- jiangchang_skill_core.llm_manager 调用 LLM -- 或在 generate_task_for_contract 里加 LLM 决策分支 +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 @@ -11,6 +12,7 @@ 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, @@ -23,7 +25,7 @@ logger = get_skill_logger() def suggest(perf_records: list[sqlite3.Row]) -> tuple[str, str]: - """基于近 3 年业绩规则生成续聘建议。 + """基于近 3 年业绩规则生成续聘建议(本地降级版,给 llm_decision 调用)。 返回 (suggestion, reason)。 @@ -70,6 +72,18 @@ def summarize(perf_records: list[sqlite3.Row]) -> str: 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, @@ -79,11 +93,34 @@ def generate_task_for_contract( """为单份合同生成 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"]) - suggestion, reason = suggest(perf) 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, @@ -93,17 +130,20 @@ def generate_task_for_contract( reason=reason, summary=summary, checklist=list(DEFAULT_MATERIALS), + decision_source=decision_source, + materials_summary=materials_summary, ) logger.info( - "task_generator: %s contract=%s → %s (%s)", - task_id, contract["contract_id"], suggestion, reason, + "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, } diff --git a/scripts/templates/task.html.j2 b/scripts/templates/task.html.j2 index e83c4b9..63fe286 100644 --- a/scripts/templates/task.html.j2 +++ b/scripts/templates/task.html.j2 @@ -112,7 +112,7 @@ - + diff --git a/scripts/util/constants.py b/scripts/util/constants.py index ed8e118..f6bbcde 100644 --- a/scripts/util/constants.py +++ b/scripts/util/constants.py @@ -6,7 +6,7 @@ """ SKILL_SLUG = "alert-contract-expiring-hr" -SKILL_VERSION = "1.1.0" +SKILL_VERSION = "1.1.1" LOG_LOGGER_NAME = "openclaw.skill.alert_contract_expiring_hr" PLATFORM_KIT_MIN_VERSION = "1.2.11"