"""任务编排层:job_context / emit / finish、鉴权与错误转换。 领域业务在 ``service/fund_service.py``(播报内核)、 ``service/project_service.py``(台账/规则/统计)、 ``service/report_service.py``(HTML 简报),由本模块调用; 多入口(CLI / Action / 对话 / cron)复用同一内核,禁止按入口分叉业务。 """ from __future__ import annotations import asyncio import json import os import uuid from typing import Any, Optional from jiangchang_skill_core import collect_runtime_diagnostics, config, format_runtime_health_lines from jiangchang_skill_core.activity import JobStopped, emit, finish, job_context from jiangchang_skill_core.rpa.video_session import RpaVideoSession from service.entitlement_service import check_entitlement from service.task_run_support import ( _print_video_summary, build_video_info, merge_video_into_result_summary, ) from util.constants import LOG_LOGGER_NAME, PLATFORM_KIT_MIN_VERSION, SKILL_SLUG, SKILL_VERSION from util.logging_config import get_skill_logger, setup_skill_logging from util.runtime_paths import get_db_path, get_skill_root from db.connection import init_db def _get_task_logger(): try: return get_skill_logger() except RuntimeError: setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME) return get_skill_logger() def _print_json(payload: dict[str, Any]) -> None: print(json.dumps(payload, ensure_ascii=False)) # --------------------------------------------------------------------------- # 模板演示入口(保留:满足 POLICY-RPA-003 录屏集成与 CLI run 契约;不承载本技能业务) # --------------------------------------------------------------------------- async def _run_template_demo(target: Optional[str], input_id: Optional[str]) -> tuple[int, dict[str, Any]]: """最小 RpaVideoSession 示范:不启动浏览器、不执行业务,仅演示录屏包裹范式。""" batch_id = uuid.uuid4().hex[:12] data_dir = get_skill_root() async with RpaVideoSession( skill_slug=SKILL_SLUG, skill_data_dir=data_dir, batch_id=batch_id, title="开始执行示例任务", closing_title="示例任务执行完成", ) as video: video.add_step("准备执行示例任务") if target: video.add_step(f"接收目标参数:{target}") if input_id: video.add_step(f"接收输入标识:{input_id}") video.add_step("示例任务执行完成") video_summary = video.summary() video_info = build_video_info(video_summary, {}, {}) return 1, video_info def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int: """模板演示入口:本技能主能力请使用 broadcast 等业务命令。""" log = _get_task_logger() task_type = "demo" with job_context(skill=SKILL_SLUG): log.info( "task_start task_type=%s target_id=%s input_id=%s stage=run", task_type, target, input_id, ) emit("开始处理任务", skill=SKILL_SLUG, stage="run") try: ok, reason = check_entitlement(SKILL_SLUG) if not ok: log.warning( "entitlement_failed task_type=%s stage=auth error_code=ENTITLEMENT_DENIED", task_type, ) emit("鉴权失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="auth") finish( status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth", ) print(f"ERROR:ENTITLEMENT_DENIED {reason}") return 1 log.info("entitlement_ok task_type=%s stage=auth", task_type) log.info("rpa_start stage=demo_prepare task_type=%s", task_type) log.info("task_run_demo_start target_id=%s input_id=%s stage=demo_prepare", target, input_id) rc, video_info = asyncio.run(_run_template_demo(target, input_id)) log.info("rpa_done stage=demo_prepare status=ok") log.info( "task_run_demo_done target_id=%s input_id=%s stage=demo_finish status=failed", target, input_id, ) _print_video_summary(video_info) summary_payload = merge_video_into_result_summary( { "template_demo": True, "target": target, "input_id": input_id, }, video_info, ) log.warning( "task_failed task_type=%s stage=demo_finish error_code=TEMPLATE_STUB", task_type, ) finish( status="failed", message="模板演示入口未承载本技能业务", skill=SKILL_SLUG, error_code="TEMPLATE_STUB", **summary_payload, ) print("ERROR:TEMPLATE_STUB 这是模板演示入口,请使用 broadcast 命令执行经费进度播报。") return rc except JobStopped: raise except Exception: log.exception( "task_failed task_type=%s target_id=%s input_id=%s", task_type, target, input_id, ) emit("任务失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="run") finish( status="failed", message="任务执行异常,详见统一日志", skill=SKILL_SLUG, stage="run", ) return 1 # --------------------------------------------------------------------------- # 基础命令 # --------------------------------------------------------------------------- def cmd_config_path() -> int: example_path = os.path.join(get_skill_root(), ".env.example") env_path = config.get_env_file_path() or "" _print_json( { "skill": SKILL_SLUG, "env_path": env_path, "example_path": os.path.abspath(example_path), } ) return 0 def cmd_health() -> int: runtime = collect_runtime_diagnostics( skill_slug=SKILL_SLUG, platform_kit_min_version=PLATFORM_KIT_MIN_VERSION, skill_root=get_skill_root(), ) example_path = os.path.join(get_skill_root(), ".env.example") env_path = config.get_env_file_path() or "" env_exists = bool(env_path and os.path.isfile(env_path)) health_status = "failed" if runtime.has_fatal_issues else "ok" lines = [ f"{SKILL_SLUG} health: {health_status}", *format_runtime_health_lines(runtime), f"env_path: {env_path}", f"env_exists: {env_exists}", f"example_path: {os.path.abspath(example_path)}", ] for line in lines: print(line) return 1 if runtime.has_fatal_issues else 0 def cmd_version() -> int: _print_json({"version": SKILL_VERSION, "skill": SKILL_SLUG}) return 0 def cmd_init_db() -> int: """幂等创建/迁移本地 SQLite 与数据管理元数据(宿主 install/upgrade 可调用)。""" log = _get_task_logger() try: seeded = init_db() except Exception as exc: # noqa: BLE001 — CLI 边界,统一成可机读失败 print( json.dumps( {"ok": False, "skill": SKILL_SLUG, "error": str(exc)}, ensure_ascii=False, ) ) return 1 _print_json( { "ok": True, "skill": SKILL_SLUG, "db_path": get_db_path(), "seeded": seeded, } ) return 0 # --------------------------------------------------------------------------- # 项目台账 # --------------------------------------------------------------------------- def cmd_add_project( *, project_no: str, name: str = "", project_type: str = "", pi: str = "", department: str = "", total_approved: Optional[float] = None, total_received: Optional[float] = None, total_spent: Optional[float] = None, frozen: Optional[float] = None, frozen_since: str = "", start: str = "", end: str = "", status: str = "", remark: str = "", ) -> int: log = _get_task_logger() from service.project_service import upsert_project with job_context(skill=SKILL_SLUG): log.info("task_start task_type=add_project project_no=%s stage=project", project_no) emit("开始写入项目台账", skill=SKILL_SLUG, stage="project") try: ok, reason = check_entitlement(SKILL_SLUG) if not ok: log.warning("entitlement_failed task_type=add_project stage=auth") finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") log.error("add_project_denied stage=auth") print(f"ERROR:ENTITLEMENT_DENIED {reason}") return 1 fields: dict[str, Any] = {"project_no": project_no} if name: fields["project_name"] = name if project_type: fields["project_type"] = project_type if pi: fields["pi_name"] = pi if department: fields["department"] = department for key, value in ( ("total_approved", total_approved), ("total_received", total_received), ("total_spent", total_spent), ("frozen_amount", frozen), ): if value is not None: fields[key] = value for key, value in (("frozen_since", frozen_since), ("start_date", start), ("end_date", end), ("status", status), ("remark", remark)): if value: fields[key] = value payload = upsert_project(fields) _print_json(payload) finish(status="success", message=f"项目 {project_no} 已{'更新' if payload['action'] == 'updated' else '录入'}", skill=SKILL_SLUG, stage="project") return 0 except ValueError as exc: log.warning("task_failed task_type=add_project error_code=PROJECT_INVALID detail=%s", exc) finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="PROJECT_INVALID", stage="project") print(f"ERROR:PROJECT_INVALID {exc}") return 1 except JobStopped: raise except Exception: log.exception("task_failed task_type=add_project stage=project") finish(status="failed", message="台账写入异常,详见统一日志", skill=SKILL_SLUG, stage="project") return 1 def cmd_import_projects(projects_json: str) -> int: log = _get_task_logger() from service.project_service import import_projects, parse_projects_json with job_context(skill=SKILL_SLUG): log.info("task_start task_type=import_projects stage=project") emit("开始批量导入项目台账", skill=SKILL_SLUG, stage="project") try: ok, reason = check_entitlement(SKILL_SLUG) if not ok: log.warning("entitlement_failed task_type=import_projects stage=auth") finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") log.error("import_projects_denied stage=auth") print(f"ERROR:ENTITLEMENT_DENIED {reason}") return 1 projects = parse_projects_json(projects_json) payload = import_projects(projects) _print_json(payload) finish(status="success", message=f"导入完成:新增 {payload['created']},更新 {payload['updated']}", skill=SKILL_SLUG, stage="project") return 0 except ValueError as exc: log.warning("task_failed task_type=import_projects error_code=PROJECT_INVALID detail=%s", exc) finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="PROJECT_INVALID", stage="project") print(f"ERROR:PROJECT_INVALID {exc}") return 1 except JobStopped: raise except Exception: log.exception("task_failed task_type=import_projects stage=project") finish(status="failed", message="台账导入异常,详见统一日志", skill=SKILL_SLUG, stage="project") return 1 def cmd_list_projects(pi: str = "", status: str = "") -> int: log = _get_task_logger() from service.project_service import list_projects with job_context(skill=SKILL_SLUG): log.info("task_start task_type=list_projects pi=%s status=%s", pi, status) try: projects = list_projects(pi_name=pi, status=status) _print_json({"ok": True, "count": len(projects), "projects": projects}) return 0 except Exception: log.exception("task_failed task_type=list_projects") print("ERROR:QUERY_FAILED 台账查询异常,详见统一日志") return 1 # --------------------------------------------------------------------------- # 播报主命令 # --------------------------------------------------------------------------- def cmd_broadcast( *, project_no: str = "", pi: str = "", as_of: str = "", include_all_status: bool = False, ) -> int: """经费进度播报(同步):全部在研项目或指定项目。""" log = _get_task_logger() from datetime import date from service.fund_service import query_projects, run_broadcast with job_context(skill=SKILL_SLUG): log.info("task_start task_type=broadcast project_no=%s pi=%s stage=broadcast", project_no, pi) emit("开始经费进度播报", skill=SKILL_SLUG, stage="broadcast") try: ok, reason = check_entitlement(SKILL_SLUG) if not ok: log.warning("entitlement_failed task_type=broadcast stage=auth") finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") log.error("broadcast_denied stage=auth") print(f"ERROR:ENTITLEMENT_DENIED {reason}") return 1 as_of_date = date.fromisoformat(as_of) if as_of else date.today() projects = query_projects(project_no=project_no, pi_name=pi, include_all_status=include_all_status) if not projects: message = "没有可播报的项目:请先录入台账(在研项目为空或筛选条件不匹配)" log.warning("task_failed task_type=broadcast error_code=NO_PROJECTS stage=broadcast") finish(status="failed", message=message, skill=SKILL_SLUG, error_code="NO_PROJECTS", stage="broadcast") log.error("broadcast_no_projects stage=broadcast") print(f"ERROR:NO_PROJECTS {message}") return 1 result = run_broadcast(projects, as_of_date) payload = {k: v for k, v in result.items() if k != "reports"} payload["reports_brief"] = [ {k: r[k] for k in ("report_id", "project_no", "project_name", "pi_name", "status_tag", "spend_rate", "time_progress", "deviation", "report_file")} for r in result["reports"] ] _print_json(payload) emit( f"播报完成 {result['count']} 个项目:严重滞后 {result['status_counts'].get('严重滞后', 0)}," f"异常偏快 {result['status_counts'].get('支出异常偏快', 0)}", skill=SKILL_SLUG, stage="broadcast", ) finish( status="success", message=f"播报完成 {result['count']} 个项目,简报已生成", skill=SKILL_SLUG, stage="broadcast", batch_no=result["batch_no"], ) return 0 except JobStopped: raise except ValueError as exc: log.warning("task_failed task_type=broadcast error_code=PROJECT_INVALID detail=%s", exc) finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="PROJECT_INVALID", stage="broadcast") print(f"ERROR:PROJECT_INVALID {exc}") return 1 except Exception: log.exception("task_failed task_type=broadcast stage=broadcast") emit("播报失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="broadcast") finish(status="failed", message="播报执行异常,详见统一日志", skill=SKILL_SLUG, stage="broadcast") return 1 def cmd_broadcast_monthly() -> int: """月度播报(async Action 入口):全部在研项目逐项播报 + 汇总简报,任务中心可见进度。""" log = _get_task_logger() from datetime import date from service.fund_service import query_projects, run_broadcast with job_context(skill=SKILL_SLUG): log.info("task_start task_type=broadcast_monthly stage=monthly") emit("开始月度经费播报", skill=SKILL_SLUG, stage="monthly") try: ok, reason = check_entitlement(SKILL_SLUG) if not ok: log.warning("entitlement_failed task_type=broadcast_monthly stage=auth") finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") log.error("broadcast_monthly_denied stage=auth") print(f"ERROR:ENTITLEMENT_DENIED {reason}") return 1 projects = query_projects() if not projects: message = "台账中没有“在研”项目,月度播报跳过" log.warning("task_failed task_type=broadcast_monthly error_code=NO_PROJECTS stage=monthly") finish(status="failed", message=message, skill=SKILL_SLUG, error_code="NO_PROJECTS", stage="monthly") log.error("broadcast_monthly_no_projects stage=monthly") print(f"ERROR:NO_PROJECTS {message}") return 1 def _emit_progress(index: int, total: int, report: dict[str, Any]) -> None: emit( f"[{index}/{total}] {report['project_name']}:{report['status_tag']}", skill=SKILL_SLUG, stage="monthly", ) result = run_broadcast(projects, date.today(), on_progress=_emit_progress) _print_json( { "ok": True, "batch_no": result["batch_no"], "as_of": result["as_of"], "count": result["count"], "status_counts": result["status_counts"], "brief_file": result["brief_file"], "reports_brief": [ {k: r[k] for k in ("report_id", "project_no", "project_name", "pi_name", "status_tag", "summary")} for r in result["reports"] ], } ) finish( status="success", message=f"月度播报完成:{result['count']} 个项目,严重滞后 {result['status_counts'].get('严重滞后', 0)} 个", skill=SKILL_SLUG, stage="monthly", batch_no=result["batch_no"], ) return 0 except JobStopped: raise except Exception: log.exception("task_failed task_type=broadcast_monthly stage=monthly") emit("月度播报失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="monthly") finish(status="failed", message="月度播报执行异常,详见统一日志", skill=SKILL_SLUG, stage="monthly") return 1 # --------------------------------------------------------------------------- # 播报记录与阈值 # --------------------------------------------------------------------------- def cmd_list_reports(limit: int = 20, status_tag: str = "") -> int: log = _get_task_logger() from service.project_service import list_reports with job_context(skill=SKILL_SLUG): log.info("task_start task_type=list_reports limit=%s status_tag=%s", limit, status_tag) try: reports = list_reports(limit=limit, status_tag=status_tag) _print_json({"ok": True, "count": len(reports), "reports": reports}) return 0 except Exception: log.exception("task_failed task_type=list_reports") print("ERROR:QUERY_FAILED 播报记录查询异常,详见统一日志") return 1 def cmd_show_report(report_id: int) -> int: log = _get_task_logger() from service.project_service import get_report with job_context(skill=SKILL_SLUG): log.info("task_start task_type=show_report report_id=%s", report_id) try: report = get_report(int(report_id)) _print_json({"ok": True, "report": report}) return 0 except ValueError as exc: log.warning("task_failed task_type=show_report error_code=REPORT_NOT_FOUND detail=%s", exc) print(f"ERROR:REPORT_NOT_FOUND {exc}") return 1 except Exception: log.exception("task_failed task_type=show_report") print("ERROR:QUERY_FAILED 播报记录查询异常,详见统一日志") return 1 def cmd_render_html(report_id: Optional[int] = None) -> int: log = _get_task_logger() from service.report_service import build_report_for_report_id from service.project_service import list_reports with job_context(skill=SKILL_SLUG): log.info("task_start task_type=render_html report_id=%s stage=render", report_id) try: if report_id: targets = [int(report_id)] else: targets = [r["id"] for r in list_reports(limit=10)] if not targets: log.warning("task_failed task_type=render_html error_code=REPORT_NOT_FOUND stage=render") finish(status="failed", message="暂无可重建的播报记录", skill=SKILL_SLUG, error_code="REPORT_NOT_FOUND", stage="render") print("ERROR:REPORT_NOT_FOUND 暂无可重建的播报记录") return 1 written = [{"report_id": rid, "path": build_report_for_report_id(rid)} for rid in targets] _print_json({"ok": True, "count": len(written), "reports": written}) finish(status="success", message=f"已重建 {len(written)} 份经费执行简报", skill=SKILL_SLUG, stage="render") return 0 except JobStopped: raise except ValueError as exc: log.warning("task_failed task_type=render_html error_code=REPORT_NOT_FOUND detail=%s", exc) finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="REPORT_NOT_FOUND", stage="render") print(f"ERROR:REPORT_NOT_FOUND {exc}") return 1 except Exception: log.exception("task_failed task_type=render_html stage=render") finish(status="failed", message="简报重建异常,详见统一日志", skill=SKILL_SLUG, stage="render") return 1 def cmd_view_rules() -> int: log = _get_task_logger() from service.project_service import view_rules with job_context(skill=SKILL_SLUG): log.info("task_start task_type=view_rules stage=rules") try: _print_json({"ok": True, "rules": view_rules()}) return 0 except Exception: log.exception("task_failed task_type=view_rules") print("ERROR:QUERY_FAILED 预警规则查询异常,详见统一日志") return 1 def cmd_set_rule(rule_key: str, value: float) -> int: log = _get_task_logger() from service.project_service import set_rule with job_context(skill=SKILL_SLUG): log.info("task_start task_type=set_rule rule_key=%s value=%s stage=rules", rule_key, value) emit("开始更新预警阈值", skill=SKILL_SLUG, stage="rules") try: ok, reason = check_entitlement(SKILL_SLUG) if not ok: log.warning("entitlement_failed task_type=set_rule stage=auth") finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") log.error("set_rule_denied stage=auth") print(f"ERROR:ENTITLEMENT_DENIED {reason}") return 1 payload = set_rule(rule_key, float(value)) _print_json(payload) finish(status="success", message=f"阈值 {rule_key} 已更新为 {value}", skill=SKILL_SLUG, stage="rules") return 0 except ValueError as exc: log.warning("task_failed task_type=set_rule error_code=RULE_INVALID detail=%s", exc) finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="RULE_INVALID", stage="rules") print(f"ERROR:RULE_INVALID {exc}") return 1 except JobStopped: raise except Exception: log.exception("task_failed task_type=set_rule stage=rules") finish(status="failed", message="阈值更新异常,详见统一日志", skill=SKILL_SLUG, stage="rules") return 1 def cmd_stats() -> int: log = _get_task_logger() from service.project_service import stats with job_context(skill=SKILL_SLUG): log.info("task_start task_type=stats") try: _print_json(stats()) return 0 except Exception: log.exception("task_failed task_type=stats") print("ERROR:QUERY_FAILED 统计查询异常,详见统一日志") return 1