v1.1.0: 实现核心业务逻辑(扫描/任务书/派发/状态机)

新增(scripts/ 分层完整实现):
- scripts/util/{constants,date_util,runtime_paths,logging_setup}.py
- scripts/db/{connection,seed_data,repository}.py(SQLite + 5 张表 + 50 份样例合同)
- scripts/service/{scanner,task_generator,dispatcher,html_renderer}.py
- scripts/cli/app.py(argparse 子命令)
- scripts/templates/task.html.j2(Jinja2 渲染,无 jinja2 时降级到 str.replace)
- examples/{contracts_sample,performance_sample}.csv(本地样例数据)

修改:
- scripts/main.py:argparse + jiangchang_skill_core 降级
- SKILL.md:version 1.0.1 → 1.1.0
- CHANGELOG.md:加 v1.1.0 条目
- .env.example:加 DB_PATH / HTML_OUTPUT_DIR / JIANGCHANG_DATA_DIR

本地验证(9 个 CLI 命令全部 exit code = 0):
- health / version / config-path / init-db:基础 4 命令
- scan-expiring-contracts --thresholds 90,60,30:42 份可续聘 → 红 10 / 橙 10 / 黄 10,生成 30 任务
- render-html:写 output/<task_id>.html(4.9 KB)
- generate-renewal-task:单份合同任务书生成
- mark-contract-renewed:状态机更新 + 关闭 open alerts
- dispatch-task-notice:按单位/月份派发 payload(dry-run 模式)

业务规则(AI 续聘建议本地版,规则顺序判断):
1. 师德 < 60 → non_renew
2. 近 3 年考核有不合格(< 60)→ observe
3. 全部 >= 80 且逐年上升 → renew
4. 其他 → observe

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-14 14:17:43 +08:00
parent e2b45d690e
commit 06c053d7d7
20 changed files with 1796 additions and 82 deletions

View File

@@ -0,0 +1,123 @@
"""派发器:按单位/月份汇总任务 → 生成派发 payload JSON → 写派发日志。
v1.1.0 dry-run 模式:payload 写 data/dispatch/{unit}_{month}.json,
但**不真正调用企业微信 API**。开发者后续接入:
- 真实 API:调用 WECOM_API_BASE_URL + WECOM_API_TOKEN
- 在 dispatch_log 写 status='sent' 而非 'dry_run'
"""
from __future__ import annotations
import json
import sqlite3
import uuid
from datetime import datetime
from pathlib import Path
from db import repository as repo
from util.date_util import parse_iso_date
from util.logging_setup import get_skill_logger
from util.runtime_paths import get_dispatch_dir
logger = get_skill_logger()
def build_payload(
conn: sqlite3.Connection,
unit_id: str,
month: str = "",
) -> dict:
"""构建派发 payload:含单位、月份、任务清单、收件人、消息正文。"""
rows = repo.list_open_alerts_by_unit_month(conn, unit_id, month)
# 单位名(取第一条)
unit_name = rows[0]["unit_name"] if rows else "(无)"
items = []
for r in rows:
items.append({
"contract_id": r["contract_id"],
"teacher_name": r["teacher_name"],
"alert_level": r["alert_level"],
"days_to_expire": r["days_to_expire"],
"contract_end": r["contract_end"],
})
if month:
title = f"{unit_name} {month} 合同到期人员清单及续聘建议"
else:
title = f"{unit_name} 待续聘合同清单"
body_lines = [
title,
"",
f"共 {len(items)} 份合同需在到期前办理续聘手续。",
"",
]
for it in items:
level_zh = {"yellow": "黄色", "orange": "橙色", "red": "红色"}.get(
it["alert_level"], it["alert_level"]
)
body_lines.append(
f"- {it['teacher_name']}(合同至 {it['contract_end']},{level_zh}预警,距到期 {it['days_to_expire']} 天)"
)
body_lines.append("")
body_lines.append("请人事秘书尽快通知相关教师备齐续聘材料。")
payload = {
"schema_version": 1,
"channel": "wecom",
"dry_run": True,
"to": {
"unit_id": unit_id,
"unit_name": unit_name,
"role": "人事秘书",
},
"title": title,
"body": "\n".join(body_lines),
"items": items,
"generated_at": datetime.now().isoformat(timespec="seconds"),
}
return payload
def dispatch(
conn: sqlite3.Connection,
unit_id: str,
month: str = "",
) -> dict:
"""派发 dry-run:写 payload 文件 + 写 dispatch_log。"""
payload = build_payload(conn, unit_id, month)
items = payload["items"]
task_ids = [f"T-{it['contract_id']}" for it in items]
dispatch_dir = get_dispatch_dir()
dispatch_dir.mkdir(parents=True, exist_ok=True)
month_part = month or "all"
payload_path = dispatch_dir / f"{unit_id}_{month_part}.json"
payload_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
log_id = f"L-{uuid.uuid4().hex[:8]}"
repo.insert_dispatch_log(
conn, log_id, unit_id, month or "all", task_ids, str(payload_path),
status="dry_run",
)
conn.commit()
logger.info(
"dispatcher: unit=%s month=%s items=%d payload=%s log=%s",
unit_id, month or "(all)", len(items), payload_path, log_id,
)
return {
"log_id": log_id,
"unit_id": unit_id,
"unit_name": payload["to"]["unit_name"],
"month": month or "(all)",
"item_count": len(items),
"payload_path": str(payload_path),
"status": "dry_run",
}

View File

@@ -0,0 +1,114 @@
"""HTML 报告渲染:Jinja2 模板 → 写 output/<task_id>.html。
模板:scripts/templates/task.html.j2
占位替换规则:
- {{TITLE}}, {{TASK_ID}}, {{GENERATED_AT}}
- {{TEACHER_NAME}}, {{EMPLOYEE_ID}}, {{UNIT_NAME}}, {{POSITION}}, {{CONTRACT_TYPE}}
- {{CONTRACT_START}}, {{CONTRACT_END}}, {{DAYS_TO_EXPIRE}}, {{ALERT_LEVEL}}
- {{PERFORMANCE_SUMMARY}}
- {{SUGGESTION_CLASS}}, {{SUGGESTION_LABEL}}, {{SUGGESTION_REASON}}
- {{DEADLINE}}, {{CHECKLIST_BODY}}
"""
from __future__ import annotations
import json
import sqlite3
from datetime import date, datetime, timedelta
from pathlib import Path
from db import repository as repo
from util.constants import ALERT_LEVEL_LABELS, SUGGESTION_LABELS
from util.date_util import parse_iso_date
from util.logging_setup import get_skill_logger
from util.runtime_paths import get_output_dir, get_templates_dir
logger = get_skill_logger()
SUGGESTION_CLASS_MAP = {
"renew": "renew",
"observe": "observe",
"non_renew": "non-renew",
}
def render_task_html(conn: sqlite3.Connection, task_id: str) -> Path:
"""渲染指定 task 为 HTML 文件,返回输出路径。
不存在 task → 抛 ValueError。
"""
task = repo.get_task(conn, task_id)
if task is None:
raise ValueError(f"task_id={task_id!r} 不存在")
# 关联 alert 的 days_to_expire / level(用于显示)
alert = conn.execute(
"SELECT * FROM alerts WHERE alert_id = ?", (task["alert_id"],)
).fetchone() if task["alert_id"] else None
days_to_expire = alert["days_to_expire"] if alert else None
alert_level = alert["alert_level"] if alert else "yellow"
alert_level_label = ALERT_LEVEL_LABELS.get(alert_level, alert_level)
# 截止日 = 合同到期日 - 30 天(教师备齐材料的最后期限)
contract_end = parse_iso_date(task["contract_end"])
deadline = (contract_end - timedelta(days=30)).isoformat()
# 模板上下文
checklist_items = json.loads(task["materials_checklist"]) if task["materials_checklist"] else []
checklist_html = "\n".join(
f' <li><span class="box"></span>{item}</li>' for item in checklist_items
)
suggestion_class = SUGGESTION_CLASS_MAP.get(task["ai_suggestion"], "observe")
suggestion_label = SUGGESTION_LABELS.get(task["ai_suggestion"], task["ai_suggestion"])
ctx = {
"TITLE": "续聘准备任务书",
"TASK_ID": task_id,
"GENERATED_AT": task["generated_at"],
"TEACHER_NAME": task["teacher_name"],
"EMPLOYEE_ID": task["employee_id"],
"UNIT_NAME": task["unit_name"],
"POSITION": task["position"],
"CONTRACT_TYPE": task["contract_type"],
"CONTRACT_START": task["contract_start"],
"CONTRACT_END": task["contract_end"],
"DAYS_TO_EXPIRE": days_to_expire if days_to_expire is not None else "—",
"ALERT_LEVEL": alert_level_label,
"PERFORMANCE_SUMMARY": task["performance_summary"],
"SUGGESTION_CLASS": suggestion_class,
"SUGGESTION_LABEL": suggestion_label,
"SUGGESTION_REASON": task["suggestion_reason"],
"DEADLINE": deadline,
"CHECKLIST_BODY": checklist_html,
}
template_path = get_templates_dir() / "task.html.j2"
if not template_path.exists():
raise FileNotFoundError(f"模板不存在: {template_path}")
html = _render_template(template_path, ctx)
output_dir = get_output_dir()
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"{task_id}.html"
output_path.write_text(html, encoding="utf-8")
logger.info("html_renderer: %s → %s", task_id, output_path)
return output_path
def _render_template(template_path: Path, ctx: dict) -> str:
"""用 Jinja2 渲染模板;本地无 jinja2 时降级到占位符替换。"""
template_text = template_path.read_text(encoding="utf-8")
try:
from jinja2 import Template
return Template(template_text).render(**ctx)
except ImportError:
# 降级:纯 str.replace(最简实现)
for k, v in ctx.items():
template_text = template_text.replace("{{" + k + "}}", str(v))
return template_text

View File

@@ -0,0 +1,88 @@
"""扫描器:扫全校合同 → 分级 → 写 alerts + 生成 renewal_tasks。
核心业务入口,cron 每日凌晨 02:00 调用。
"""
from __future__ import annotations
import sqlite3
from datetime import date
from typing import Tuple
from db import repository as repo
from service.task_generator import generate_task_for_contract
from util.constants import ALERT_LEVELS, DEFAULT_THRESHOLDS
from util.date_util import classify_alert, days_to, parse_iso_date
from util.logging_setup import get_skill_logger
logger = get_skill_logger()
def scan(
conn: sqlite3.Connection,
thresholds: Tuple[int, int, int] = DEFAULT_THRESHOLDS,
today: date | None = None,
scan_id: str | None = None,
) -> dict:
"""扫描全校合同,返回结构化结果。
thresholds = (yellow_max, orange_max, red_max),默认 (90, 60, 30)。
返回:
{
"scanned_count": <int>,
"yellow_count": <int>,
"orange_count": <int>,
"red_count": <int>,
"generated_task_count": <int>,
"scan_id": <str>,
"thresholds": [yellow_max, orange_max, red_max],
}
"""
today = today or date.today()
scan_id = scan_id or f"S{datetime_now_compact()}"
contracts = repo.list_active_contracts(conn)
counts = {f"{lvl}_count": 0 for lvl in ALERT_LEVELS}
generated = 0
logger.info("scan: start scan_id=%s thresholds=%s today=%s contracts=%d",
scan_id, thresholds, today.isoformat(), len(contracts))
with conn:
for c in contracts:
contract_end = parse_iso_date(c["contract_end"])
days = days_to(contract_end, today)
level = classify_alert(days, thresholds)
if level is None:
continue
# 写 alert(alert_id 包含 scan_id 避免同一合同被多次扫描时冲突)
alert_id = f"A-{c['contract_id']}-{scan_id}"
repo.insert_alert(conn, alert_id, c["contract_id"], level, days, scan_id)
counts[f"{level}_count"] += 1
# 生成任务书
task_id = f"T-{c['contract_id']}-{scan_id}"
task = generate_task_for_contract(conn, c, alert_id, task_id)
if task:
generated += 1
result = {
"scanned_count": len(contracts),
"yellow_count": counts["yellow_count"],
"orange_count": counts["orange_count"],
"red_count": counts["red_count"],
"generated_task_count": generated,
"scan_id": scan_id,
"thresholds": list(thresholds),
"today": today.isoformat(),
}
logger.info("scan: done %s", result)
return result
def datetime_now_compact() -> str:
"""生成紧凑时间字符串,用于 alert_id / task_id / scan_id。"""
from datetime import datetime
return datetime.now().strftime("%Y%m%d%H%M%S")

View File

@@ -0,0 +1,145 @@
"""任务书生成器:基于规则生成 AI 续聘建议 + 性能摘要 + 材料清单。
本地规则版(v1.1.0):不调用 LLM。
后续可替换为:
- jiangchang_skill_core.llm_manager 调用 LLM
- 或在 generate_task_for_contract 里加 LLM 决策分支
"""
from __future__ import annotations
import sqlite3
from db import repository as repo
from util.constants import (
DEFAULT_MATERIALS,
MORAL_FAIL_THRESHOLD,
PERFORMANCE_FAIL_THRESHOLD,
PERFORMANCE_RENEW_MIN,
)
from util.logging_setup import get_skill_logger
logger = get_skill_logger()
def suggest(perf_records: list[sqlite3.Row]) -> tuple[str, str]:
"""基于近 3 年业绩规则生成续聘建议。
返回 (suggestion, reason)。
规则(顺序判断):
1. 师德评分 < MORAL_FAIL_THRESHOLD(60)→ non_renew
2. 任意一年考核 < PERFORMANCE_FAIL_THRESHOLD → observe
3. 全部 >= PERFORMANCE_RENEW_MIN 且逐年上升 → renew
4. 其他 → observe
"""
if not perf_records:
return ("observe", "近 3 年业绩数据缺失,需人工补录")
# 取最近一条作为师德评分参考(最新一年)
moral = perf_records[-1]["moral_teaching_score"]
if moral < MORAL_FAIL_THRESHOLD:
return ("non_renew", f"师德师风考评 {moral:.0f} 分,未达标")
scores = [p["performance_score"] for p in perf_records]
fail_years = sum(1 for s in scores if s < PERFORMANCE_FAIL_THRESHOLD)
if fail_years >= 1:
return ("observe", f"近 3 年考核有 {fail_years} 次不合格(< {PERFORMANCE_FAIL_THRESHOLD} 分),建议复核")
# 全部 >= 80 且逐年上升(升序排 → 严格递增)
all_high = all(s >= PERFORMANCE_RENEW_MIN for s in scores)
upward = all(scores[i] <= scores[i + 1] for i in range(len(scores) - 1))
if all_high and upward:
return ("renew", f"近 3 年考核均 ≥ {PERFORMANCE_RENEW_MIN} 分且逐年上升")
return ("observe", "进入常规续聘评审流程")
def summarize(perf_records: list[sqlite3.Row]) -> str:
"""生成近 3 年业绩摘要文本(HTML 任务书里 pre-wrap 显示)。"""
if not perf_records:
return "(暂无业绩数据,需补录)"
lines = []
for p in perf_records:
lines.append(
f"{p['year']} 年度:综合考核 {p['performance_score']:.0f} 分"
f"(教学 {p['teaching_score']:.0f} / 科研 {p['research_score']:.0f})"
f",师德 {p['moral_teaching_score']:.0f} 分"
)
return "\n".join(lines)
def generate_task_for_contract(
conn: sqlite3.Connection,
contract: sqlite3.Row,
alert_id: str,
task_id: str,
) -> dict | None:
"""为单份合同生成 renewal_task。
返回写入库的 task 摘要 dict(不入库时返回 None)。
"""
perf = repo.list_performance_by_contract(conn, contract["contract_id"])
suggestion, reason = suggest(perf)
summary = summarize(perf)
repo.insert_task(
conn,
task_id=task_id,
contract_id=contract["contract_id"],
alert_id=alert_id,
suggestion=suggestion,
reason=reason,
summary=summary,
checklist=list(DEFAULT_MATERIALS),
)
logger.info(
"task_generator: %s contract=%s → %s (%s)",
task_id, contract["contract_id"], suggestion, reason,
)
return {
"task_id": task_id,
"contract_id": contract["contract_id"],
"ai_suggestion": suggestion,
"suggestion_reason": reason,
}
def regenerate_task_for_contract_id(
conn: sqlite3.Connection, contract_id: str, task_id: str | None = None
) -> dict:
"""单独 generate-renewal-task CLI 入口:基于已有合同 + 最新 alert 生成任务。
如果没有 alert,自动创建 yellow 级占位 alert(合同已临近)。
"""
contract = repo.get_contract(conn, contract_id)
if contract is None:
raise ValueError(f"contract_id={contract_id!r} 不存在")
# 取最新 open alert(如果有)
alert = conn.execute(
"""
SELECT * FROM alerts
WHERE contract_id = ? AND status = 'open'
ORDER BY generated_at DESC LIMIT 1
""",
(contract_id,),
).fetchone()
if alert is None:
# 自动建一个 yellow 级 placeholder alert
from util.date_util import days_to, parse_iso_date
days = days_to(parse_iso_date(contract["contract_end"]))
from util.constants import DEFAULT_THRESHOLDS
from util.date_util import classify_alert
level = classify_alert(days, DEFAULT_THRESHOLDS) or "yellow"
import uuid
alert_id = f"A-{contract_id}-manual-{uuid.uuid4().hex[:6]}"
repo.insert_alert(conn, alert_id, contract_id, level, days, scan_id="manual")
alert = {"alert_id": alert_id, "alert_level": level, "days_to_expire": days}
task_id = task_id or f"T-{contract_id}-manual"
return generate_task_for_contract(conn, contract, alert["alert_id"], task_id)