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)

134
scripts/db/connection.py Normal file
View File

@@ -0,0 +1,134 @@
"""SQLite 连接 + DDL 定义 + init_db()。
5 张表:
- contracts: 合同台账
- performance_records: 近 3 年业绩记录
- alerts: 预警记录
- renewal_tasks: 续聘任务
- dispatch_log: 派发日志
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
from typing import Iterable
from util.runtime_paths import ensure_dirs, get_db_path
# DDL:所有 IF NOT EXISTS,幂等可重入
DDL_STATEMENTS: tuple[str, ...] = (
"""
CREATE TABLE IF NOT EXISTS contracts (
contract_id TEXT PRIMARY KEY,
employee_id TEXT NOT NULL,
teacher_name TEXT NOT NULL,
unit_id TEXT NOT NULL,
unit_name TEXT NOT NULL,
position TEXT NOT NULL,
contract_type TEXT NOT NULL,
contract_start TEXT NOT NULL,
contract_end TEXT NOT NULL,
trial_period_end TEXT NOT NULL,
status TEXT NOT NULL DEFAULT '可续聘',
last_material_id TEXT,
renewed_at TEXT
);
""",
"""
CREATE TABLE IF NOT EXISTS performance_records (
record_id TEXT PRIMARY KEY,
contract_id TEXT NOT NULL,
year INTEGER NOT NULL,
performance_score REAL NOT NULL,
moral_teaching_score REAL NOT NULL,
research_score REAL NOT NULL,
teaching_score REAL NOT NULL,
FOREIGN KEY (contract_id) REFERENCES contracts(contract_id)
);
""",
"CREATE INDEX IF NOT EXISTS idx_perf_contract ON performance_records(contract_id);",
"""
CREATE TABLE IF NOT EXISTS alerts (
alert_id TEXT PRIMARY KEY,
contract_id TEXT NOT NULL,
alert_level TEXT NOT NULL,
days_to_expire INTEGER NOT NULL,
scan_id TEXT NOT NULL,
generated_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
FOREIGN KEY (contract_id) REFERENCES contracts(contract_id)
);
""",
"CREATE INDEX IF NOT EXISTS idx_alerts_contract ON alerts(contract_id);",
"CREATE INDEX IF NOT EXISTS idx_alerts_status ON alerts(status);",
"""
CREATE TABLE IF NOT EXISTS renewal_tasks (
task_id TEXT PRIMARY KEY,
contract_id TEXT NOT NULL,
alert_id TEXT,
ai_suggestion TEXT NOT NULL,
suggestion_reason TEXT NOT NULL,
performance_summary TEXT NOT NULL,
materials_checklist TEXT NOT NULL,
generated_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
FOREIGN KEY (contract_id) REFERENCES contracts(contract_id),
FOREIGN KEY (alert_id) REFERENCES alerts(alert_id)
);
""",
"CREATE INDEX IF NOT EXISTS idx_tasks_contract ON renewal_tasks(contract_id);",
"""
CREATE TABLE IF NOT EXISTS dispatch_log (
log_id TEXT PRIMARY KEY,
unit_id TEXT NOT NULL,
month TEXT NOT NULL,
task_ids TEXT NOT NULL,
dispatched_at TEXT NOT NULL,
payload_path TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'dry_run'
);
""",
"CREATE INDEX IF NOT EXISTS idx_dispatch_unit_month ON dispatch_log(unit_id, month);",
)
def get_connection(db_path: Path | None = None) -> sqlite3.Connection:
"""获取 SQLite 连接。"""
ensure_dirs()
path = db_path or get_db_path()
conn = sqlite3.connect(str(path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON;")
return conn
def init_db(db_path: Path | None = None) -> dict[str, int]:
"""建表 + 加载种子数据。返回表行数 dict。"""
ensure_dirs()
conn = get_connection(db_path)
try:
for ddl in DDL_STATEMENTS:
conn.execute(ddl)
conn.commit()
# 加载种子(如果 contracts 表为空)
from db.seed_data import load_seed_if_empty
load_seed_if_empty(conn)
# 统计
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"],
}
return counts
finally:
conn.close()
def table_names() -> Iterable[str]:
return ("contracts", "performance_records", "alerts", "renewal_tasks", "dispatch_log")

195
scripts/db/repository.py Normal file
View File

@@ -0,0 +1,195 @@
"""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()
# ---------- 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],
) -> 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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')
""",
(
task_id,
contract_id,
alert_id,
suggestion,
reason,
summary,
json.dumps(checklist, ensure_ascii=False),
datetime.now().isoformat(timespec="seconds"),
),
)
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()
# ---------- 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()

90
scripts/db/seed_data.py Normal file
View File

@@ -0,0 +1,90 @@
"""种子数据加载:从 examples/contracts_sample.csv 和 performance_sample.csv 读入。
只在 contracts 表为空时执行,幂等。
真实部署应替换为人事系统 API 调用。
"""
from __future__ import annotations
import csv
import sqlite3
from pathlib import Path
from util.logging_setup import get_skill_logger
from util.runtime_paths import get_examples_dir
logger = get_skill_logger()
def _read_csv(path: Path) -> list[dict[str, str]]:
with open(path, "r", encoding="utf-8", newline="") as f:
return list(csv.DictReader(f))
def load_seed_if_empty(conn: sqlite3.Connection) -> tuple[int, int]:
"""如果 contracts 表为空,从 CSV 加载种子数据。返回 (合同数, 业绩数)。"""
n = conn.execute("SELECT COUNT(*) AS n FROM contracts").fetchone()["n"]
if n > 0:
logger.info("seed_data: contracts 表已有 %d 条记录,跳过加载", n)
return (n, 0)
examples_dir = get_examples_dir()
contracts_csv = examples_dir / "contracts_sample.csv"
performance_csv = examples_dir / "performance_sample.csv"
if not contracts_csv.exists() or not performance_csv.exists():
logger.warning("seed_data: 样例 CSV 不存在 %s / %s", contracts_csv, performance_csv)
return (0, 0)
contracts = _read_csv(contracts_csv)
performance = _read_csv(performance_csv)
with conn:
conn.executemany(
"""
INSERT OR REPLACE INTO contracts
(contract_id, employee_id, teacher_name, unit_id, unit_name,
position, contract_type, contract_start, contract_end,
trial_period_end, status, last_material_id, renewed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
""",
[
(
c["contract_id"],
c["employee_id"],
c["teacher_name"],
c["unit_id"],
c["unit_name"],
c["position"],
c["contract_type"],
c["contract_start"],
c["contract_end"],
c["trial_period_end"],
c["status"],
)
for c in contracts
],
)
conn.executemany(
"""
INSERT OR REPLACE INTO performance_records
(record_id, contract_id, year, performance_score,
moral_teaching_score, research_score, teaching_score)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
[
(
p["record_id"],
p["contract_id"],
int(p["year"]),
float(p["performance_score"]),
float(p["moral_teaching_score"]),
float(p["research_score"]),
float(p["teaching_score"]),
)
for p in performance
],
)
logger.info("seed_data: 加载 %d 份合同 / %d 条业绩记录", len(contracts), len(performance))
return (len(contracts), len(performance))

View File

@@ -1,91 +1,45 @@
#!/usr/bin/env python3
"""合同到期智能预警与续聘辅助 - CLI 入口(占位骨架)"""
# -*- coding: utf-8 -*-
"""合同到期智能预警与续聘辅助 - CLI 入口。
v1.1.0 实现:
- argparse 子命令分发
- jiangchang_skill_core 降级(宿主环境用完整版,本地用自包含实现)
- UTF-8 输出兼容(Windows cmd)
"""
from __future__ import annotations
import io
import os
import sys
from pathlib import Path
# 占位:开发者后续接入 jiangchang_skill_core
# from jiangchang_skill_core import ...
# UTF-8 输出兼容(Windows)
if sys.platform == "win32":
if hasattr(sys.stdout, "buffer"):
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "buffer"):
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
BASE_DIR = Path(__file__).resolve().parent.parent
# scripts/main.py → scripts/ → 加进 sys.path
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
if _scripts_dir not in sys.path:
sys.path.insert(0, _scripts_dir)
def health():
"""运行检查(占位)。"""
print(f"alert-contract-expiring-hr health check (placeholder)")
print(f"base_dir: {BASE_DIR}")
return 0
def _apply_host_runtime() -> None:
"""宿主共享 runtime 集成(本地无 jiangchang_skill_core 时跳过)。"""
try:
from jiangchang_skill_core.runtime_env import apply_cli_local_defaults
apply_cli_local_defaults()
except ImportError:
pass # 本地环境没有宿主 runtime,跳过
def version():
"""查看版本(占位)。"""
print("alert-contract-expiring-hr v1.0.1")
return 0
def config_path():
"""查看配置位置(占位)。"""
print(f"{BASE_DIR / '.env'}")
return 0
def init_db():
"""初始化数据库(占位)。"""
print("init-db: 后续由开发者按 references/SCHEMA.md 实现")
return 0
def scan_expiring_contracts(thresholds: str = "90,60,30"):
"""扫描即将到期合同(async)。"""
print(f"scan-expiring-contracts: thresholds={thresholds} (待开发者实现)")
return 0
def generate_renewal_task(contract_id: str):
"""生成续聘任务书。"""
print(f"generate-renewal-task: contract_id={contract_id} (待开发者实现)")
return 0
def dispatch_task_notice(unit_id: str = "", month: str = ""):
"""下发续聘任务通知单。"""
print(f"dispatch-task-notice: unit_id={unit_id} month={month} (待开发者实现)")
return 0
def mark_contract_renewed(contract_id: str, material_id: str):
"""标记合同已续签。"""
print(f"mark-contract-renewed: contract_id={contract_id} material_id={material_id} (待开发者实现)")
return 0
def render_html(task_id: str):
"""在浏览器中打开续聘任务书。"""
print(f"render-html: task_id={task_id} (待开发者实现)")
return 0
COMMANDS = {
"health": health,
"version": version,
"config-path": config_path,
"init-db": init_db,
"scan-expiring-contracts": scan_expiring_contracts,
"generate-renewal-task": generate_renewal_task,
"dispatch-task-notice": dispatch_task_notice,
"mark-contract-renewed": mark_contract_renewed,
"render-html": render_html,
}
def main(argv=None):
argv = argv or sys.argv[1:]
if not argv or argv[0] not in COMMANDS:
print("Usage: python scripts/main.py <command>")
print("Commands:", ", ".join(COMMANDS.keys()))
return 1
return COMMANDS[argv[0]](*argv[1:])
_apply_host_runtime()
from cli.app import main
if __name__ == "__main__":
sys.exit(main())
raise SystemExit(main())

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)

View File

@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>{{TITLE}}</title>
<style>
/* 数据管理 iframe 预览布局基线 */
html, body {
margin: 0;
padding: 16px;
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: #1f2329;
background: #fafbfc;
line-height: 1.6;
}
.report { max-width: 100%; }
.header {
padding: 20px;
background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%);
color: #fff;
border-radius: 8px;
margin-bottom: 16px;
}
.header h1 { margin: 0 0 8px; font-size: 22px; }
.header .meta { color: #dbeafe; font-size: 13px; }
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.info-card {
padding: 12px 16px;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 8px;
}
.info-card .label { color: #8c959f; font-size: 12px; }
.info-card .value { font-size: 16px; font-weight: 600; margin-top: 4px; word-break: break-word; }
.section {
padding: 16px 20px;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 8px;
margin-bottom: 16px;
}
.section h2 { margin: 0 0 12px; font-size: 16px; color: #1e3a8a; }
.summary-text { white-space: pre-wrap; color: #4e5969; }
.ai-suggestion {
padding: 14px 18px;
border-left: 4px solid #3b82f6;
background: #eff6ff;
border-radius: 4px;
color: #1e40af;
}
.ai-suggestion.renew { border-left-color: #059669; background: #ecfdf5; color: #065f46; }
.ai-suggestion.observe { border-left-color: #d97706; background: #fffbeb; color: #92400e; }
.ai-suggestion.non-renew { border-left-color: #c53030; background: #fef2f2; color: #991b1b; }
.checklist { list-style: none; padding: 0; margin: 0; }
.checklist li {
padding: 8px 0;
border-bottom: 1px solid #f0f1f3;
display: flex;
align-items: center;
gap: 8px;
}
.checklist li:last-child { border-bottom: 0; }
.checklist .box {
width: 16px; height: 16px;
border: 1px solid #cbd5e1;
border-radius: 3px;
flex-shrink: 0;
}
.footer { margin-top: 16px; color: #8c959f; font-size: 12px; text-align: right; }
</style>
</head>
<body>
<div class="report">
<div class="header">
<h1>{{TITLE}}</h1>
<div class="meta">任务编号:{{TASK_ID}} · 生成时间:{{GENERATED_AT}}</div>
</div>
<div class="info-grid">
<div class="info-card"><div class="label">教职工姓名</div><div class="value">{{TEACHER_NAME}}</div></div>
<div class="info-card"><div class="label">工号</div><div class="value">{{EMPLOYEE_ID}}</div></div>
<div class="info-card"><div class="label">所在单位</div><div class="value">{{UNIT_NAME}}</div></div>
<div class="info-card"><div class="label">聘任岗位</div><div class="value">{{POSITION}}</div></div>
<div class="info-card"><div class="label">合同类型</div><div class="value">{{CONTRACT_TYPE}}</div></div>
<div class="info-card"><div class="label">合同起止</div><div class="value">{{CONTRACT_START}} ~ {{CONTRACT_END}}</div></div>
<div class="info-card"><div class="label">距到期日</div><div class="value">{{DAYS_TO_EXPIRE}} 天</div></div>
<div class="info-card"><div class="label">预警等级</div><div class="value">{{ALERT_LEVEL}}</div></div>
</div>
<div class="section">
<h2>近三年工作业绩摘要</h2>
<div class="summary-text">{{PERFORMANCE_SUMMARY}}</div>
</div>
<div class="section">
<h2>AI 续聘建议</h2>
<div class="ai-suggestion {{SUGGESTION_CLASS}}">
<strong>{{SUGGESTION_LABEL}}</strong><br />
{{SUGGESTION_REASON}}
</div>
</div>
<div class="section">
<h2>所需材料清单(请于 {{DEADLINE}} 前备齐)</h2>
<ul class="checklist">
{{CHECKLIST_BODY}}
</ul>
</div>
<div class="footer">本任务书由「合同到期智能预警与续聘辅助」技能自动生成 · 最终续聘决定权归人事处</div>
</div>
</body>
</html>

45
scripts/util/constants.py Normal file
View File

@@ -0,0 +1,45 @@
"""技能标识、版本与阈值常量。
复制到新 skill 时必须修改:
- SKILL_SLUG 与 SKILL.md metadata.openclaw.slug 保持一致
- SKILL_VERSION 与 SKILL.md version 保持一致
"""
SKILL_SLUG = "alert-contract-expiring-hr"
SKILL_VERSION = "1.1.0"
LOG_LOGGER_NAME = "openclaw.skill.alert_contract_expiring_hr"
PLATFORM_KIT_MIN_VERSION = "1.2.11"
# 预警阈值(黄/橙/红):距到期日 <= yellow 天 = 黄色预警
# 顺序:red < orange < yellow
DEFAULT_THRESHOLDS = (90, 60, 30)
# 预警等级名称(与 thresholds 顺序一一对应)
ALERT_LEVELS = ("yellow", "orange", "red")
ALERT_LEVEL_LABELS = {"yellow": "黄色", "orange": "橙色", "red": "红色"}
# 续聘建议
SUGGESTIONS = ("renew", "observe", "non_renew")
SUGGESTION_LABELS = {
"renew": "建议续聘",
"observe": "常规续聘评审",
"non_renew": "不建议续聘",
}
# 合同状态
CONTRACT_STATUSES = ("可续聘", "预警中", "已续签", "已驳回", "已过期", "已撤回")
# 续签材料清单
DEFAULT_MATERIALS = [
"近 3 年年度考核表(盖章)",
"师德师风考评记录",
"学历学位证书复印件",
"岗位聘任登记表",
"劳动合同文本(一式三份)",
"个人述职报告(不少于 2000 字)",
]
# AI 续聘建议规则阈值
MORAL_FAIL_THRESHOLD = 60 # 师德 < 60 直接 non_renew
PERFORMANCE_FAIL_THRESHOLD = 60 # 单年考核 < 60 算不合格
PERFORMANCE_RENEW_MIN = 80 # 全部 >= 80 且逐年上升 → renew

64
scripts/util/date_util.py Normal file
View File

@@ -0,0 +1,64 @@
"""日期工具:距到期日天数、月份匹配、ISO 字符串解析。"""
from __future__ import annotations
from datetime import date, datetime
from typing import Tuple
def parse_iso_date(s: str) -> date:
"""解析 'YYYY-MM-DD' ISO 日期字符串。
容错:
- 已经是 date 实例 → 直接返回
- 含 'T' 的 ISO datetime → 取 date 部分
"""
if isinstance(s, date):
return s
s = str(s).strip()
if "T" in s:
s = s.split("T", 1)[0]
return datetime.strptime(s, "%Y-%m-%d").date()
def days_to(target: date, today: date | None = None) -> int:
"""计算距 target 还有多少天(负数 = 已过期)。"""
today = today or date.today()
return (target - today).days
def classify_alert(days: int, thresholds: Tuple[int, int, int] = (90, 60, 30)) -> str | None:
"""根据距到期天数返回预警等级。
thresholds = (yellow_max, orange_max, red_max),须 yellow_max > orange_max > red_max。
返回:'yellow' / 'orange' / 'red' / None(不在预警范围)。
"""
yellow_max, orange_max, red_max = thresholds
if days > yellow_max:
return None
if days <= red_max:
return "red"
if days <= orange_max:
return "orange"
return "yellow"
def month_match(date_to_check: date, month_str: str) -> bool:
"""判断日期是否落在 month_str('YYYY-MM')月份内。"""
if not month_str:
return True
try:
y, m = month_str.strip().split("-")
return date_to_check.year == int(y) and date_to_check.month == int(m)
except (ValueError, AttributeError):
return False
def today_iso() -> str:
"""返回今天日期的 ISO 字符串。"""
return date.today().isoformat()
def now_iso() -> str:
"""返回当前时间的 ISO 字符串。"""
return datetime.now().isoformat(timespec="seconds")

View File

@@ -0,0 +1,53 @@
"""logging 配置:降级到 stderr,避免污染 stdout(stdout 留给结构化输出如 JSON)。
优先用 jiangchang_skill_core 提供的 logging;本地无该包时降级到 stderr。
"""
from __future__ import annotations
import logging
import sys
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG, SKILL_VERSION
_configured = False
def setup_skill_logging() -> logging.Logger:
"""初始化 skill logger(幂等)。"""
global _configured
logger = logging.getLogger(LOG_LOGGER_NAME)
if _configured:
return logger
# 优先用宿主提供的 skill logger 配置
try:
from jiangchang_skill_core.logging_config import setup_skill_logging as _host_setup
_host_setup(SKILL_SLUG, LOG_LOGGER_NAME)
_configured = True
return logging.getLogger(LOG_LOGGER_NAME)
except ImportError:
pass
# 本地降级:基础 stderr handler,UTF-8 safe
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(
logging.Formatter(
fmt=f"%(asctime)s [{SKILL_SLUG}] %(levelname)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
logger.setLevel(logging.INFO)
logger.addHandler(handler)
logger.propagate = False
_configured = True
logger.info("logging initialized (local fallback) v%s", SKILL_VERSION)
return logger
def get_skill_logger() -> logging.Logger:
"""获取 skill logger(不会重复 setup)。"""
if not _configured:
return setup_skill_logging()
return logging.getLogger(LOG_LOGGER_NAME)

View File

@@ -0,0 +1,82 @@
"""运行时路径解析。
本地环境:
- 仓库根 = scripts/main.py 的父目录的父目录
- data/ 在仓库根下
- output/ 在仓库根下
- .env 在仓库根下
宿主环境(jiangchang_skill_core 提供)会覆写这些路径到
{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{slug}/...
"""
from __future__ import annotations
import os
from pathlib import Path
from util.constants import SKILL_SLUG
# util/runtime_paths.py → scripts/util/ → scripts/ → 仓库根(再上两级)
_THIS_FILE = Path(__file__).resolve()
_SCRIPTS_DIR = _THIS_FILE.parent.parent # scripts/
_SKILL_ROOT = _SCRIPTS_DIR.parent # alert-contract-expiring-hr/
def get_skill_root() -> Path:
"""返回 skill 仓库根目录。"""
# 优先尊重环境变量(宿主设置过)
env_root = os.environ.get("JIANGCHANG_SKILL_ROOT")
if env_root:
return Path(env_root)
return _SKILL_ROOT
def get_data_dir() -> Path:
"""返回 SQLite DB 与种子数据目录。"""
env_dir = os.environ.get("JIANGCHANG_DATA_DIR")
if env_dir:
return Path(env_dir)
return get_skill_root() / "data"
def get_db_path() -> Path:
"""返回 SQLite DB 文件路径。"""
env_db = os.environ.get("DB_PATH")
if env_db:
return Path(env_db)
return get_data_dir() / f"{SKILL_SLUG}.db"
def get_output_dir() -> Path:
"""返回 HTML 报告输出目录。"""
env_out = os.environ.get("HTML_OUTPUT_DIR")
if env_out:
return Path(env_out)
return get_skill_root() / "output"
def get_env_path() -> Path:
"""返回 .env 路径(仓库根 / 当前工作目录)。"""
return get_skill_root() / ".env"
def get_dispatch_dir() -> Path:
"""返回派发 payload JSON 输出目录。"""
return get_data_dir() / "dispatch"
def get_examples_dir() -> Path:
"""返回样例数据目录。"""
return get_skill_root() / "examples"
def get_templates_dir() -> Path:
"""返回 Jinja2 模板目录。"""
return _SCRIPTS_DIR.parent / "scripts" / "templates"
def ensure_dirs() -> None:
"""确保所有需要的目录存在。"""
for d in (get_data_dir(), get_output_dir(), get_dispatch_dir()):
d.mkdir(parents=True, exist_ok=True)