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,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