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

274
scripts/cli/app.py Normal file
View File

@@ -0,0 +1,274 @@
"""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
- render-html
"""
from __future__ import annotations
import argparse
import json
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 util.constants import DEFAULT_THRESHOLDS, SKILL_VERSION
from util.logging_setup import get_skill_logger, setup_skill_logging
from util.runtime_paths import (
get_db_path,
get_dispatch_dir,
get_env_path,
get_output_dir,
)
logger = get_skill_logger()
# ---------- 命令实现 ----------
def cmd_health(_args: argparse.Namespace) -> int:
"""健康检查。"""
setup_skill_logging()
db_path = get_db_path()
db_exists = db_path.exists()
db_writable = False
error_msg = None
try:
conn = get_connection()
try:
conn.execute("SELECT 1").fetchone()
db_writable = True
finally:
conn.close()
except Exception as e:
error_msg = str(e)
output_dir = get_output_dir()
dispatch_dir = get_dispatch_dir()
checks = [
("python_version", f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", True),
("db_path", str(db_path), True),
("db_exists", str(db_exists), db_exists),
("db_writable", str(db_writable), db_writable),
("output_dir", str(output_dir), True),
("dispatch_dir", str(dispatch_dir), True),
]
if db_exists and db_writable:
try:
conn = get_connection()
try:
counts = {
"contracts": conn.execute("SELECT COUNT(*) AS n FROM contracts").fetchone()["n"],
"performance_records": conn.execute("SELECT COUNT(*) AS n FROM performance_records").fetchone()["n"],
"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"],
}
finally:
conn.close()
checks.append(("seed_loaded", f"contracts={counts['contracts']} perf={counts['performance_records']}", counts["contracts"] > 0))
except Exception as e:
checks.append(("seed_check", f"ERROR: {e}", False))
all_ok = all(c[2] for c in checks)
for name, val, ok in checks:
marker = "OK " if ok else "FAIL"
print(f"[{marker}] {name}: {val}")
if error_msg:
print(f"[FAIL] error: {error_msg}")
if all_ok:
print("OK: health check passed")
return 0
print("ERROR: health check failed")
return 1
def cmd_version(_args: argparse.Namespace) -> int:
print(f"{SKILL_VERSION}")
return 0
def cmd_config_path(_args: argparse.Namespace) -> int:
print(get_env_path())
return 0
def cmd_init_db(_args: argparse.Namespace) -> int:
counts = init_db()
print(f"OK: init-db complete")
for k, v in counts.items():
print(f" {k}: {v} rows")
return 0
def cmd_scan(args: argparse.Namespace) -> int:
thresholds = _parse_thresholds(args.thresholds)
conn = get_connection()
try:
result = scanner.scan(conn, thresholds=thresholds)
finally:
conn.close()
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
def cmd_generate_task(args: argparse.Namespace) -> int:
contract_id = args.contract_id
task_id = args.task_id
conn = get_connection()
try:
result = task_generator.regenerate_task_for_contract_id(conn, contract_id, task_id)
conn.commit()
except ValueError as e:
print(f"ERROR: {e}")
return 2
finally:
conn.close()
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
def cmd_dispatch(args: argparse.Namespace) -> int:
conn = get_connection()
try:
result = dispatcher.dispatch(conn, args.unit_id, args.month or "")
finally:
conn.close()
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
def cmd_mark_renewed(args: argparse.Namespace) -> int:
conn = get_connection()
try:
from db import repository as repo
contract = repo.get_contract(conn, args.contract_id)
if contract is None:
print(f"ERROR: contract_id={args.contract_id!r} 不存在")
return 2
with conn:
n = repo.update_contract_status(conn, args.contract_id, "已续签", args.material_id)
closed = repo.close_open_alerts_for_contract(conn, args.contract_id)
print(json.dumps({
"contract_id": args.contract_id,
"status": "已续签",
"material_id": args.material_id,
"updated_rows": n,
"closed_alerts": closed,
}, ensure_ascii=False, indent=2))
finally:
conn.close()
return 0
def cmd_render_html(args: argparse.Namespace) -> int:
conn = get_connection()
try:
output_path = html_renderer.render_task_html(conn, args.task_id)
except ValueError as e:
print(f"ERROR: {e}")
return 2
except FileNotFoundError as e:
print(f"ERROR: {e}")
return 3
finally:
conn.close()
print(json.dumps({
"task_id": args.task_id,
"output_path": str(output_path),
"size_bytes": output_path.stat().st_size,
}, ensure_ascii=False, indent=2))
return 0
# ---------- argparse ----------
def _parse_thresholds(s: str) -> tuple[int, int, int]:
parts = [int(p.strip()) for p in s.split(",")]
if len(parts) != 3 or sorted(parts, reverse=True) != parts:
raise argparse.ArgumentTypeError(
f"thresholds 必须是 3 个整数(黄,橙,红)且从大到小,例如 90,60,30;收到 {s!r}"
)
return (parts[0], parts[1], parts[2])
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="main.py",
description=f"合同到期智能预警与续聘辅助 v{SKILL_VERSION}",
)
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("health", help="健康检查(Python / DB / 目录)").set_defaults(handler=cmd_health)
sub.add_parser("version", help="查看版本").set_defaults(handler=cmd_version)
sub.add_parser("config-path", help="查看 .env 路径").set_defaults(handler=cmd_config_path)
sub.add_parser("init-db", help="建表 + 加载样例数据").set_defaults(handler=cmd_init_db)
sp = sub.add_parser(
"scan-expiring-contracts", help="扫描即将到期合同(async/cron)",
)
sp.add_argument(
"--thresholds", default="90,60,30",
help="三级预警阈值(黄,橙,红)从大到小,默认 90,60,30",
)
sp.set_defaults(handler=cmd_scan)
sp = sub.add_parser(
"generate-renewal-task", help="为指定合同生成续聘任务书",
)
sp.add_argument("--contract-id", required=True, help="合同 ID")
sp.add_argument("--task-id", default=None, help="可选:指定 task_id(默认自动生成)")
sp.set_defaults(handler=cmd_generate_task)
sp = sub.add_parser(
"dispatch-task-notice", help="按单位/月份派发续聘通知(dry-run)",
)
sp.add_argument("--unit-id", required=True, help="二级单位 ID")
sp.add_argument("--month", default="", help="月份 YYYY-MM(留空 = 全部)")
sp.set_defaults(handler=cmd_dispatch)
sp = sub.add_parser(
"mark-contract-renewed", help="标记合同已续签",
)
sp.add_argument("--contract-id", required=True, help="合同 ID")
sp.add_argument("--material-id", required=True, help="续签材料 ID")
sp.set_defaults(handler=cmd_mark_renewed)
sp = sub.add_parser(
"render-html", help="渲染任务书 HTML",
)
sp.add_argument("--task-id", required=True, help="任务 ID")
sp.set_defaults(handler=cmd_render_html)
return p
def main(argv: Optional[List[str]] = None) -> int:
argv = argv if argv is not None else sys.argv[1:]
if not argv:
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")
return 1
setup_skill_logging()
parser = build_parser()
args = parser.parse_args(argv)
handler: Callable[[argparse.Namespace], int] = args.handler
return handler(args)