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>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
56
scripts/db/migrations.py
Normal file
56
scripts/db/migrations.py
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
153
scripts/service/llm_decision.py
Normal file
153
scripts/service/llm_decision.py
Normal file
@@ -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)
|
||||
143
scripts/service/material_parser.py
Normal file
143
scripts/service/material_parser.py
Normal file
@@ -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)
|
||||
268
scripts/service/platform_collector.py
Normal file
268
scripts/service/platform_collector.py
Normal file
@@ -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"
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer">本任务书由「合同到期智能预警与续聘辅助」技能自动生成 · 最终续聘决定权归人事处</div>
|
||||
<div class="footer">本任务书由「合同到期智能预警与续聘辅助」技能自动生成 · AI 建议来源:{{DECISION_SOURCE}} · 已审阅材料 {{MATERIALS_COUNT}} 份 · 最终续聘决定权归人事处</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user