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:
123
scripts/service/dispatcher.py
Normal file
123
scripts/service/dispatcher.py
Normal 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",
|
||||
}
|
||||
114
scripts/service/html_renderer.py
Normal file
114
scripts/service/html_renderer.py
Normal 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
|
||||
88
scripts/service/scanner.py
Normal file
88
scripts/service/scanner.py
Normal 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")
|
||||
145
scripts/service/task_generator.py
Normal file
145
scripts/service/task_generator.py
Normal 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)
|
||||
Reference in New Issue
Block a user