Files
alert-contract-expiring-hr/scripts/db/repository.py
taojianmin 4af2b4fbf3 v1.1.2: sandbox 卡卡片文本采集 + 解析为 performance 记录
sandbox 行为新发现:
1. 「续聘材料包」按钮不是下载文件,而是触发页面下方显示「续聘准备任务书」
   +「单位清单」卡片(含 checklist + 近三年考核表)
2. 进入 hr/contracts 页面后需要刷新(page.reload)才能看到表格行
3. 表格列序扩展为 10 列:工号 / 姓名 / 单位 / 岗位 / 合同类型 / 起止日期 / 剩余天数 / 预警 / 建议 / 材料包按钮
4. contract_id 改用工号(稳定,不随姓名变化)

改动:
- platform_collector.collect_all:
  * 进入 hr/contracts 后 page.reload 刷新触发 SPA mock 数据加载
  * 新列序映射:r[0]=工号 r[1]=姓名 r[2]=单位 r[3]=岗位 r[4]=合同类型
    r[5]=起止日期 r[6]=剩余天数 r[7]=预警 r[8]=建议
  * 合同 end_date 从「2024-03-01 ~ 2026-04-15」截取 ~ 后的部分
  * 点「续聘材料包」按钮 → 抓下方卡片 inner_text(含 checklist + 考核等次)
  * 把卡片文本存入 materials_content(parse_method='sandbox_card')
- _persist_contracts 增加 sandbox_card 路径分支:写 materials_content 后同步调
  repo.parse_sandbox_card_performance → 抽 year + 等次 → 写 performance_records
- repository.parse_sandbox_card_performance:双模式正则匹配
  * 行格式 "2024\t称职"
  * 叙述式 "2024年:等次「称职」"
- repository.upsert_performance_record + 等次 → 分数映射
  (优秀=90 称职=75 基本称职=60 不称职=30)

实测(sandbox 3 合同):
- RPA-T20190033-20260415 钱实验 → 解析 4 条 performance
- RPA-T20200055-20260630 孙助理 → 解析 6 条
- RPA-T20180021-20260831 王明远 → 解析 6 条
- generate-renewal-task 全部输出 observe + decision_source=rules

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 16:01:20 +08:00

376 lines
11 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.
"""CRUD 仓储:contracts / performance_records / alerts / renewal_tasks / dispatch_log。
所有方法接收一个外部传入的 sqlite3.Connection,调用方负责事务边界。
"""
from __future__ import annotations
import json
import sqlite3
from datetime import date, datetime
from typing import Any, Iterable
# ---------- contracts ----------
def list_active_contracts(conn: sqlite3.Connection) -> list[sqlite3.Row]:
"""列出 status='可续聘' 的合同。"""
return conn.execute(
"SELECT * FROM contracts WHERE status = '可续聘' ORDER BY contract_end ASC"
).fetchall()
def get_contract(conn: sqlite3.Connection, contract_id: str) -> sqlite3.Row | None:
return conn.execute(
"SELECT * FROM contracts WHERE contract_id = ?", (contract_id,)
).fetchone()
def update_contract_status(
conn: sqlite3.Connection,
contract_id: str,
status: str,
material_id: str | None = None,
) -> int:
"""更新合同状态。material_id 仅在 status='已续签' 时写入。"""
renewed_at = datetime.now().isoformat(timespec="seconds") if status == "已续签" else None
cur = conn.execute(
"""
UPDATE contracts
SET status = ?,
last_material_id = COALESCE(?, last_material_id),
renewed_at = COALESCE(?, renewed_at)
WHERE contract_id = ?
""",
(status, material_id, renewed_at, contract_id),
)
return cur.rowcount
# ---------- performance ----------
def list_performance_by_contract(
conn: sqlite3.Connection, contract_id: str
) -> list[sqlite3.Row]:
"""某合同的业绩记录,按年份升序。"""
return conn.execute(
"SELECT * FROM performance_records WHERE contract_id = ? ORDER BY year ASC",
(contract_id,),
).fetchall()
# 等次 → 分数映射(v1.1.1 sandbox card 解析)
_GRADE_TO_SCORE = {
"优秀": 90.0,
"称职": 75.0,
"基本称职": 60.0,
"不称职": 30.0,
"不合格": 30.0,
}
def upsert_performance_record(
conn: sqlite3.Connection,
contract_id: str,
year: int,
grade: str,
) -> bool:
"""upsert 一条业绩记录(用 grade 映射分数)。
sandbox card 文本不含真分数时使用。返回是否实际写入。
"""
score = _GRADE_TO_SCORE.get(grade)
if score is None:
return False
record_id = f"P-{contract_id}-{year}"
conn.execute(
"""
INSERT INTO performance_records
(record_id, contract_id, year, performance_score,
moral_teaching_score, research_score, teaching_score)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(record_id) DO UPDATE SET
performance_score = excluded.performance_score
""",
(record_id, contract_id, year, score, score, score, score),
)
return True
def parse_sandbox_card_performance(
conn: sqlite3.Connection,
contract_id: str,
card_text: str,
) -> int:
"""从 sandbox 卡片文本抽 (year, 等次) → upsert 到 performance_records。
文本片段形如:
year result
2024 称职
2025 优秀
2024年:等次「称职」。
返回写入条数。
"""
import re
if not card_text:
return 0
written = 0
# 模式 1:行格式 "2024\t称职"
for m in re.finditer(r"^(\d{4})\s*[ \t]+(优秀|称职|基本称职|不称职|不合格)\s*$",
card_text, re.MULTILINE):
year = int(m.group(1))
grade = m.group(2)
if upsert_performance_record(conn, contract_id, year, grade):
written += 1
# 模式 2:叙述式 "2024年:等次「称职」"
for m in re.finditer(r"(\d{4})\s*年[::]\s*等次[「\"']\s*(优秀|称职|基本称职|不称职|不合格)\s*[」\"']",
card_text):
year = int(m.group(1))
grade = m.group(2)
if upsert_performance_record(conn, contract_id, year, grade):
written += 1
return written
# ---------- alerts ----------
def insert_alert(
conn: sqlite3.Connection,
alert_id: str,
contract_id: str,
level: str,
days_to_expire: int,
scan_id: str,
) -> None:
conn.execute(
"""
INSERT OR REPLACE INTO alerts
(alert_id, contract_id, alert_level, days_to_expire, scan_id, generated_at, status)
VALUES (?, ?, ?, ?, ?, ?, 'open')
""",
(
alert_id,
contract_id,
level,
days_to_expire,
scan_id,
datetime.now().isoformat(timespec="seconds"),
),
)
def close_open_alerts_for_contract(conn: sqlite3.Connection, contract_id: str) -> int:
"""将某合同所有 open 状态的 alerts 关闭(关联 '已续签' 等终态)。"""
cur = conn.execute(
"UPDATE alerts SET status = 'closed' WHERE contract_id = ? AND status = 'open'",
(contract_id,),
)
return cur.rowcount
def list_open_alerts_by_unit_month(
conn: sqlite3.Connection, unit_id: str, month: str
) -> list[sqlite3.Row]:
"""按单位/月份列出关联合同的 open alerts(用于派发汇总)。"""
sql = """
SELECT a.alert_id, a.contract_id, a.alert_level, a.days_to_expire,
c.contract_end, c.teacher_name, c.unit_name
FROM alerts a
JOIN contracts c ON c.contract_id = a.contract_id
WHERE a.status = 'open'
AND c.unit_id = ?
"""
params: list[Any] = [unit_id]
if month:
sql += " AND substr(c.contract_end, 1, 7) = ?"
params.append(month)
sql += " ORDER BY c.contract_end ASC"
return conn.execute(sql, params).fetchall()
# ---------- renewal_tasks ----------
def insert_task(
conn: sqlite3.Connection,
task_id: str,
contract_id: str,
alert_id: str,
suggestion: str,
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,
decision_source, materials_summary)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
""",
(
task_id,
contract_id,
alert_id,
suggestion,
reason,
summary,
json.dumps(checklist, ensure_ascii=False),
datetime.now().isoformat(timespec="seconds"),
decision_source,
materials_summary,
),
)
def get_task(conn: sqlite3.Connection, task_id: str) -> sqlite3.Row | None:
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
FROM renewal_tasks t
JOIN contracts c ON c.contract_id = t.contract_id
WHERE t.task_id = ?
""",
(task_id,),
).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(
conn: sqlite3.Connection,
log_id: str,
unit_id: str,
month: str,
task_ids: list[str],
payload_path: str,
status: str = "dry_run",
) -> None:
conn.execute(
"""
INSERT INTO dispatch_log
(log_id, unit_id, month, task_ids, dispatched_at, payload_path, status)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
log_id,
unit_id,
month,
json.dumps(task_ids, ensure_ascii=False),
datetime.now().isoformat(timespec="seconds"),
payload_path,
status,
),
)
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)