#!/usr/bin/env python3 from __future__ import annotations import argparse import json import os import shlex import subprocess import tempfile from datetime import UTC, datetime from decimal import Decimal from pathlib import Path from typing import Any import clickhouse_connect ROOT = Path(__file__).resolve().parents[1] PROMPT_PATH = ROOT / "ai" / "recovery_brief_prompt.md" SCHEMA_PATH = ROOT / "ai" / "recovery_brief_schema.json" def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description="Generate recovery brief for analytics_1c") p.add_argument("--host", default=os.getenv("CLICKHOUSE_HOST", "localhost")) p.add_argument("--port", type=int, default=int(os.getenv("CLICKHOUSE_PORT", "8123"))) p.add_argument("--user", default=os.getenv("CLICKHOUSE_USER", "default")) p.add_argument("--password", default=os.getenv("CLICKHOUSE_PASSWORD", "")) p.add_argument("--database", default=os.getenv("CLICKHOUSE_DB", "analytics_1c")) p.add_argument( "--brief-state-dir", default=os.getenv("AW_1C_MANAGER_BRIEF_STATE_DIR", str(ROOT / "state" / "manager-brief")), ) p.add_argument( "--weekly-digest-state-dir", default=os.getenv("AW_1C_WEEKLY_DIGEST_STATE_DIR", str(ROOT / "state" / "weekly-digest")), ) p.add_argument( "--state-dir", default=os.getenv("AW_1C_RECOVERY_BRIEF_STATE_DIR", str(ROOT / "state" / "recovery-brief")), ) p.add_argument( "--codex-user", default=os.getenv("AW_1C_MANAGER_BRIEF_CODEX_USER", "codex"), ) p.add_argument( "--codex-bin", default=os.getenv("AW_1C_MANAGER_BRIEF_CODEX_BIN", "codex"), ) p.add_argument( "--workdir", default=os.getenv("AW_1C_MANAGER_BRIEF_WORKDIR", "/home/codex/infra-admin"), ) p.add_argument( "--model", default=os.getenv("AW_1C_MANAGER_BRIEF_MODEL", "gpt-5.3-codex"), ) p.add_argument( "--timeout-sec", type=int, default=int(os.getenv("AW_1C_MANAGER_BRIEF_TIMEOUT_SEC", "300")), ) p.add_argument( "--top-limit", type=int, default=int(os.getenv("AW_1C_RECOVERY_BRIEF_TOP_LIMIT", "6")), ) return p.parse_args() def to_plain(value: Any) -> Any: if isinstance(value, Decimal): return float(value) if isinstance(value, datetime): return value.isoformat() return value def rows_to_dict(result) -> list[dict[str, Any]]: return [ {name: to_plain(value) for name, value in zip(result.column_names, row)} for row in result.result_rows ] def q(value: str) -> str: return "'" + value.replace("'", "''") + "'" def ch_client(args: argparse.Namespace): return clickhouse_connect.get_client( host=args.host, port=args.port, username=args.user, password=args.password, database=args.database, ) def load_text(path: Path) -> str: return path.read_text(encoding="utf-8") def write_text(path: Path, content: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") def write_json(path: Path, payload: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def load_json_if_exists(path: Path) -> dict[str, Any] | None: if not path.exists(): return None return json.loads(path.read_text(encoding="utf-8")) def load_latest_json(state_dir: Path) -> dict[str, Any] | None: return load_json_if_exists(state_dir / "latest.json") def build_context(client, args: argparse.Namespace) -> dict[str, Any]: latest_brief = load_latest_json(Path(args.brief_state_dir)) or {} latest_weekly_digest = load_latest_json(Path(args.weekly_digest_state_dir)) or {} problematic = rows_to_dict( client.query( f""" WITH recent AS ( SELECT infobase, counterparty, max(generated_at) AS latest_signal_at, max(score) AS max_score, sum(score) AS total_score, count() AS signals_total, countIf(severity = 'critical') AS critical_total, countIf(severity = 'high') AS high_total, argMax(severity, tuple(score, generated_at)) AS top_severity, argMax(signal_type, tuple(score, generated_at)) AS top_signal_type, argMax(summary, tuple(score, generated_at)) AS top_summary FROM analytics_1c.company_health_signals WHERE generated_at >= now() - INTERVAL 7 DAY GROUP BY infobase, counterparty ) SELECT p.infobase AS infobase, p.company_entity_key AS company_entity_key, p.counterparty AS counterparty, p.source_counterparty, p.company_name, p.normalized_counterparty, p.registry_match_mode, p.registry_assignee_name, p.registry_status, p.signal_severity, p.signal_score, round(p.amount_30d, 2) AS amount_30d, round(p.amount_forecast_30d, 2) AS amount_forecast_30d, p.current_status, p.active_locks, p.open_cases_total, p.detections_total, r.latest_signal_at, r.max_score, r.total_score, r.signals_total, r.critical_total, r.high_total, r.top_severity, r.top_signal_type, r.top_summary FROM recent AS r INNER JOIN analytics_1c.v_company_portfolio_overview AS p ON p.infobase = r.infobase AND p.counterparty = r.counterparty ORDER BY r.max_score DESC, r.signals_total DESC, p.open_cases_total DESC, p.amount_30d DESC, p.counterparty LIMIT {int(args.top_limit)} """ ) ) return { "generated_at": datetime.now(UTC).isoformat(), "latest_brief": { "generated_at": latest_brief.get("generated_at"), "headline": latest_brief.get("brief", {}).get("headline"), "summary": latest_brief.get("brief", {}).get("summary", []), "delta": latest_brief.get("context", {}).get("delta", {}), "portfolio_summary": latest_brief.get("context", {}).get("portfolio_summary", {}), }, "latest_weekly_digest": { "generated_at": latest_weekly_digest.get("generated_at"), "headline": latest_weekly_digest.get("digest", {}).get("headline"), "summary": latest_weekly_digest.get("digest", {}).get("summary", []), "top_priorities": latest_weekly_digest.get("digest", {}).get("top_priorities", []), }, "problematic_companies": problematic, } def render_deterministic_recovery(context: dict[str, Any]) -> dict[str, Any]: latest_brief = context.get("latest_brief", {}) portfolio = latest_brief.get("portfolio_summary", {}) delta = latest_brief.get("delta", {}) problematic = context.get("problematic_companies", []) headline = ( f"Recovery-контур: хвост кейсов {portfolio.get('open_cases_total', 0)}, " f"critical {portfolio.get('critical_total', 0)}/{portfolio.get('companies_total', 0)}." ) situation = [ f"Открытых кейсов {portfolio.get('open_cases_total', 0)}, detections {portfolio.get('detections_total', 0)}.", f"С последнего запуска: open cases {int(delta.get('summary', {}).get('open_cases_total_delta', 0) or 0):+d}, detections {int(delta.get('summary', {}).get('detections_total_delta', 0) or 0):+d}.", "Проблема в накоплении operational-хвоста, а не в резком обвале активности портфеля.", ] portfolio_actions = [ "Сжать фокус до 5–6 компаний первой очереди и перестать размазывать контроль по всему портфелю.", "По каждой компании первой очереди фиксировать только: причина, владелец, срок, факт снижения кейсов.", "Сначала убирать рост открытых кейсов и блокировок, а не обсуждать общий красный фон.", ] top_incidents = [] for item in problematic[:6]: company = str(item.get("counterparty") or item.get("company_name") or "-") actions = [ "Проверить владельца и состав открытых кейсов по компании.", "Подтвердить, что по компании есть план снижения хвоста в ближайшие 24 часа.", ] if int(item.get("active_locks") or 0) > 0: actions.append("Проверить busy/lock-контур базы и не держать блокировки без владельца.") if item.get("registry_match_mode") == "manual": actions.append("Сначала подтвердить корректность manual-сопоставления.") top_incidents.append( { "company_entity_key": str(item.get("company_entity_key") or ""), "company": company, "severity": str(item.get("signal_severity") or item.get("top_severity") or "critical"), "diagnosis": ( f"Открытые кейсы {item.get('open_cases_total')}, detections {item.get('detections_total')}, " f"блокировки {item.get('active_locks')}, top signal: {item.get('top_signal_type') or '-'}." ), "actions": actions[:4], "stop_doing": ( "Не разбирать компанию общими совещаниями без владельца и без числовой цели на день." ), "target_state_24h": ( f"Снижение открытых кейсов ниже {max(int(item.get('open_cases_total') or 0) - 3, 0)} и отсутствие нового прироста по следующему запуску." ), } ) caveats = [ "Operational severity не равна финансовому кризису портфеля.", "Manual/alias сопоставления нельзя трактовать как окончательное юридическое соответствие без проверки.", ] return { "headline": headline, "situation": situation[:6], "portfolio_actions": portfolio_actions[:6], "top_incidents": top_incidents, "caveats": caveats[:5], } def render_markdown(payload: dict[str, Any], generated_at: str) -> str: lines = [ "# Recovery Brief 1C", "", f"_Сформировано: {generated_at}_", "", "## Заголовок", payload["headline"], "", "## Ситуация", ] for item in payload["situation"]: lines.append(f"- {item}") lines.extend(["", "## Действия по портфелю"]) for item in payload["portfolio_actions"]: lines.append(f"- {item}") lines.extend(["", "## Компании первой очереди"]) for idx, item in enumerate(payload["top_incidents"], start=1): lines.append(f"{idx}. {item['company']} [{item['severity']}] — {item['diagnosis']}") for action in item["actions"]: lines.append(f" - {action}") lines.append(f" - Стоп: {item['stop_doing']}") lines.append(f" - Цель 24ч: {item['target_state_24h']}") lines.extend(["", "## Ограничения"]) for item in payload["caveats"]: lines.append(f"- {item}") lines.append("") return "\n".join(lines) def run_codex(prompt: str, args: argparse.Namespace) -> tuple[int, str, str]: output_file = Path(tempfile.mkstemp(prefix="aw-1c-recovery-brief-", suffix=".json")[1]) os.chmod(output_file, 0o666) cmd_inner = ( f"cd {shlex.quote(args.workdir)} && " f"{shlex.quote(args.codex_bin)} exec --ephemeral --skip-git-repo-check " f"--model {shlex.quote(args.model)} " f"-C {shlex.quote(args.workdir)} " f"-s read-only " f"--color never " f"--output-schema {shlex.quote(str(SCHEMA_PATH))} " f"-o {shlex.quote(str(output_file))} -" ) if os.geteuid() == 0 and args.codex_user: cmd = ["sudo", "-u", args.codex_user, "-H", "bash", "-lc", cmd_inner] else: cmd = ["bash", "-lc", cmd_inner] try: result = subprocess.run( cmd, input=prompt, text=True, capture_output=True, timeout=args.timeout_sec, check=False, ) reply = output_file.read_text(encoding="utf-8").strip() if output_file.exists() else "" stdout_stderr = (result.stdout or "") + ("\n" + result.stderr if result.stderr else "") return result.returncode, stdout_stderr.strip(), reply finally: try: output_file.unlink() except FileNotFoundError: pass def build_prompt(context: dict[str, Any]) -> str: template = load_text(PROMPT_PATH) return template.replace("{{CONTEXT_JSON}}", json.dumps(context, ensure_ascii=False, indent=2)) def save_artifacts(state_dir: Path, artifact: dict[str, Any], markdown: str) -> None: timestamp = datetime.fromisoformat(artifact["generated_at"]).strftime("%Y%m%dT%H%M%SZ") history_dir = state_dir / "history" history_dir.mkdir(parents=True, exist_ok=True) write_json(state_dir / "latest.json", artifact) write_text(state_dir / "latest.md", markdown) write_json(history_dir / f"{timestamp}.json", artifact) write_text(history_dir / f"{timestamp}.md", markdown) def main() -> int: args = parse_args() state_dir = Path(args.state_dir) state_dir.mkdir(parents=True, exist_ok=True) client = ch_client(args) context = build_context(client, args) prompt = build_prompt(context) render_mode = "deterministic" model = "deterministic" payload = render_deterministic_recovery(context) codex_output = "" try: rc, stdout_stderr, reply = run_codex(prompt, args) codex_output = stdout_stderr if rc == 0 and reply: candidate = json.loads(reply) if candidate: payload = candidate render_mode = "codex" model = args.model except Exception as exc: # noqa: BLE001 codex_output = f"{codex_output}\nFALLBACK: {exc}".strip() generated_at = datetime.now(UTC).replace(microsecond=0).isoformat() markdown = render_markdown(payload, generated_at) artifact = { "generated_at": generated_at, "render_mode": render_mode, "model": model, "context": context, "recovery": payload, "markdown": markdown, "codex_output_excerpt": codex_output[-4000:] if codex_output else "", } save_artifacts(state_dir, artifact, markdown) print(json.dumps({"status": "ok", "render_mode": render_mode, "state_dir": str(state_dir)}, ensure_ascii=False)) return 0 if __name__ == "__main__": raise SystemExit(main())