Files
AWatch-rus/clickhouse-1c/ai/generate_recovery_brief.py
T

389 lines
16 KiB
Python

#!/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())