feat(1c): add codex recovery brief layer

This commit is contained in:
igor04091968
2026-05-22 16:46:32 +03:00
parent 4f1b935bdc
commit be4609cd25
9 changed files with 958 additions and 0 deletions
@@ -69,6 +69,14 @@ def weekly_digest_state_dir() -> Path:
return root / "state" / "weekly-digest" return root / "state" / "weekly-digest"
def recovery_brief_state_dir() -> Path:
root = Path(os.getenv("AW_1C_ROOT", "/opt/activitywatch/clickhouse-1c"))
configured = os.getenv("AW_1C_RECOVERY_BRIEF_STATE_DIR")
if configured:
return Path(configured)
return root / "state" / "recovery-brief"
def load_latest_manager_brief() -> dict[str, Any]: def load_latest_manager_brief() -> dict[str, Any]:
latest_path = manager_brief_state_dir() / "latest.json" latest_path = manager_brief_state_dir() / "latest.json"
if not latest_path.exists(): if not latest_path.exists():
@@ -83,6 +91,13 @@ def load_latest_weekly_digest() -> dict[str, Any]:
return json.loads(latest_path.read_text(encoding="utf-8")) return json.loads(latest_path.read_text(encoding="utf-8"))
def load_latest_recovery_brief() -> dict[str, Any]:
latest_path = recovery_brief_state_dir() / "latest.json"
if not latest_path.exists():
raise HTTPException(status_code=404, detail="recovery brief not generated yet")
return json.loads(latest_path.read_text(encoding="utf-8"))
def load_brief_history_records(limit: int = 20) -> list[dict[str, Any]]: def load_brief_history_records(limit: int = 20) -> list[dict[str, Any]]:
history_dir = manager_brief_state_dir() / "history" history_dir = manager_brief_state_dir() / "history"
if not history_dir.exists(): if not history_dir.exists():
@@ -366,6 +381,81 @@ def build_company_priority_context(summary_payload: dict[str, Any], infobase: st
} }
def find_recovery_incident_for_company(
items: list[dict[str, Any]],
counterparty: str,
infobase: str | None = None,
) -> dict[str, Any] | None:
fallback: dict[str, Any] | None = None
for item in items:
if str(item.get("company") or "") != counterparty:
continue
if infobase and str(item.get("infobase") or "") == infobase:
return item
if fallback is None:
fallback = item
return fallback
def build_company_recovery_context(summary_payload: dict[str, Any], infobase: str | None = None) -> dict[str, Any]:
card = summary_payload.get("card") or {}
priority_context = summary_payload.get("priority_context") or build_company_priority_context(summary_payload, infobase)
counterparty = str(card.get("counterparty") or "")
matched_incident = None
recovery_generated_at = None
try:
recovery_payload = load_latest_recovery_brief()
matched_incident = find_recovery_incident_for_company(
recovery_payload.get("recovery", {}).get("top_incidents", []),
counterparty,
infobase or str(card.get("infobase") or "") or None,
)
recovery_generated_at = recovery_payload.get("generated_at")
except HTTPException:
recovery_payload = None
if matched_incident:
diagnosis = str(matched_incident.get("diagnosis") or priority_context.get("current_priority_reason") or "-")
actions = [str(item) for item in matched_incident.get("actions", []) if str(item).strip()]
stop_doing = str(matched_incident.get("stop_doing") or "")
target_state = str(matched_incident.get("target_state_24h") or "")
confidence = "recovery-brief/codex"
else:
diagnosis_parts = [str(priority_context.get("current_priority_reason") or "").strip()]
if int(card.get("open_cases_total") or 0) > 0:
diagnosis_parts.append(f"открытых кейсов {int(card.get('open_cases_total') or 0)}")
if int(card.get("active_locks") or 0) > 0:
diagnosis_parts.append(f"активных блокировок {int(card.get('active_locks') or 0)}")
if int(card.get("detections_total") or 0) > 0:
diagnosis_parts.append(f"detections {int(card.get('detections_total') or 0)}")
diagnosis = "; ".join(part for part in diagnosis_parts if part) or "явного recovery-диагноза пока нет"
actions = list(priority_context.get("actions", []))
if int(card.get("open_cases_total") or 0) > 0 and "Назначить владельца на закрытие открытых кейсов в течение 24 часов." not in actions:
actions.insert(0, "Назначить владельца на закрытие открытых кейсов в течение 24 часов.")
if int(card.get("active_locks") or 0) > 0 and "Снять busy/lock-контур прежде чем обсуждать долгосрочный прогноз." not in actions:
actions.append("Снять busy/lock-контур прежде чем обсуждать долгосрочный прогноз.")
if card.get("registry_match_mode") == "manual" and "Проверить корректность manual-сопоставления до жёстких управленческих выводов." not in actions:
actions.append("Проверить корректность manual-сопоставления до жёстких управленческих выводов.")
stop_doing = "Не проводить общие обсуждения без владельца, срока и числовой цели на день."
target_cases = max(int(card.get("open_cases_total") or 0) - 3, 0)
target_state = (
f"Снизить открытые кейсы ниже {target_cases}, "
f"снять новый прирост и подтвердить отсутствие лишних блокировок по следующему запуску."
)
confidence = "deterministic-fallback"
return {
"generated_at": recovery_generated_at,
"confidence": confidence,
"diagnosis": diagnosis,
"actions": actions[:5],
"stop_doing": stop_doing,
"target_state_24h": target_state,
"source_incident": matched_incident,
}
def grafana_company_dashboard_url() -> str: def grafana_company_dashboard_url() -> str:
return os.getenv( return os.getenv(
"AW_1C_MANAGER_BRIEF_GRAFANA_URL", "AW_1C_MANAGER_BRIEF_GRAFANA_URL",
@@ -422,6 +512,7 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str:
delta_html_url = "/manager/changes" delta_html_url = "/manager/changes"
weekly_html_url = "/manager/trends/weekly" weekly_html_url = "/manager/trends/weekly"
weekly_digest_url = "/manager/digest/weekly" weekly_digest_url = "/manager/digest/weekly"
recovery_html_url = "/manager/recovery"
problematic_1d_url = "/manager/problematic?days=1" problematic_1d_url = "/manager/problematic?days=1"
problematic_7d_url = "/manager/problematic?days=7" problematic_7d_url = "/manager/problematic?days=7"
json_url = "/api/1/analytics-1c/manager/brief/latest" json_url = "/api/1/analytics-1c/manager/brief/latest"
@@ -749,6 +840,7 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str:
<a href="{html.escape(delta_html_url)}">Что изменилось</a> <a href="{html.escape(delta_html_url)}">Что изменилось</a>
<a href="{html.escape(weekly_html_url)}">Неделя</a> <a href="{html.escape(weekly_html_url)}">Неделя</a>
<a href="{html.escape(weekly_digest_url)}">Weekly digest</a> <a href="{html.escape(weekly_digest_url)}">Weekly digest</a>
<a href="{html.escape(recovery_html_url)}">AI recovery</a>
<a href="{html.escape(problematic_1d_url)}">Проблемные 1д</a> <a href="{html.escape(problematic_1d_url)}">Проблемные 1д</a>
<a href="{html.escape(problematic_7d_url)}">Проблемные 7д</a> <a href="{html.escape(problematic_7d_url)}">Проблемные 7д</a>
<a href="{html.escape(history_url)}">History API</a> <a href="{html.escape(history_url)}">History API</a>
@@ -965,6 +1057,7 @@ def render_brief_history_html(items: list[dict[str, Any]]) -> str:
<a href="/manager/brief">Текущий brief</a> <a href="/manager/brief">Текущий brief</a>
<a href="/manager/changes">Что изменилось</a> <a href="/manager/changes">Что изменилось</a>
<a href="/manager/trends/weekly">Неделя</a> <a href="/manager/trends/weekly">Неделя</a>
<a href="/manager/recovery">AI recovery</a>
<a href="/manager/problematic?days=1">Проблемные 1д</a> <a href="/manager/problematic?days=1">Проблемные 1д</a>
<a href="/manager/problematic?days=7">Проблемные 7д</a> <a href="/manager/problematic?days=7">Проблемные 7д</a>
</div> </div>
@@ -1054,6 +1147,7 @@ def render_problematic_companies_html(items: list[dict[str, Any]], days: int) ->
<a href="/manager/brief">Текущий brief</a> <a href="/manager/brief">Текущий brief</a>
<a href="/manager/briefs">История brief</a> <a href="/manager/briefs">История brief</a>
<a href="/manager/trends/weekly">Неделя</a> <a href="/manager/trends/weekly">Неделя</a>
<a href="/manager/recovery">AI recovery</a>
<a href="/manager/problematic?days=1">Срез 1д</a> <a href="/manager/problematic?days=1">Срез 1д</a>
<a href="/manager/problematic?days=7">Срез 7д</a> <a href="/manager/problematic?days=7">Срез 7д</a>
</div> </div>
@@ -1200,6 +1294,7 @@ def render_brief_delta_html(payload: dict[str, Any]) -> str:
<a href="/manager/briefs">История brief</a> <a href="/manager/briefs">История brief</a>
<a href="/manager/trends/weekly">Неделя</a> <a href="/manager/trends/weekly">Неделя</a>
<a href="/manager/digest/weekly">Weekly digest</a> <a href="/manager/digest/weekly">Weekly digest</a>
<a href="/manager/recovery">AI recovery</a>
<a href="/manager/problematic?days=1">Проблемные 1д</a> <a href="/manager/problematic?days=1">Проблемные 1д</a>
<a href="/manager/problematic?days=7">Проблемные 7д</a> <a href="/manager/problematic?days=7">Проблемные 7д</a>
</div> </div>
@@ -1356,6 +1451,7 @@ def render_weekly_trend_html(report: dict[str, Any]) -> str:
<a href="/manager/changes">Что изменилось</a> <a href="/manager/changes">Что изменилось</a>
<a href="/manager/briefs">История brief</a> <a href="/manager/briefs">История brief</a>
<a href="/manager/problematic?days=7">Проблемные 7д</a> <a href="/manager/problematic?days=7">Проблемные 7д</a>
<a href="/manager/recovery">AI recovery</a>
<a href="/manager/digest/weekly">Weekly digest</a> <a href="/manager/digest/weekly">Weekly digest</a>
</div> </div>
</section> </section>
@@ -1513,6 +1609,7 @@ def render_weekly_digest_html(payload: dict[str, Any]) -> str:
<a href="/manager/changes">Что изменилось</a> <a href="/manager/changes">Что изменилось</a>
<a href="/manager/trends/weekly">Неделя</a> <a href="/manager/trends/weekly">Неделя</a>
<a href="/manager/problematic?days=7">Проблемные 7д</a> <a href="/manager/problematic?days=7">Проблемные 7д</a>
<a href="/manager/recovery">AI recovery</a>
<a href="/api/1/analytics-1c/manager/digest/weekly/latest">JSON</a> <a href="/api/1/analytics-1c/manager/digest/weekly/latest">JSON</a>
<a href="/api/1/analytics-1c/manager/digest/weekly/latest.md">Markdown</a> <a href="/api/1/analytics-1c/manager/digest/weekly/latest.md">Markdown</a>
</div> </div>
@@ -1560,6 +1657,101 @@ def render_weekly_digest_html(payload: dict[str, Any]) -> str:
</html>""" </html>"""
def render_recovery_brief_html(payload: dict[str, Any]) -> str:
recovery = payload.get("recovery", {})
generated_at = payload.get("generated_at", "")
render_mode = payload.get("render_mode", "unknown")
situation_items = "".join(f"<li>{html.escape(str(item))}</li>" for item in recovery.get("situation", []))
action_items = "".join(f"<li>{html.escape(str(item))}</li>" for item in recovery.get("portfolio_actions", []))
caveat_items = "".join(f"<li>{html.escape(str(item))}</li>" for item in recovery.get("caveats", []))
incident_cards = []
for item in recovery.get("top_incidents", []):
company = str(item.get("company") or "-")
actions = "".join(f"<li>{html.escape(str(action))}</li>" for action in item.get("actions", []))
incident_cards.append(
"<article class=\"stack-card\">"
f"<div class=\"stack-card-head\"><h3>{html.escape(company)}</h3>{severity_badge(str(item.get('severity') or 'critical'))}</div>"
f"<p class=\"stack-card-body\"><strong>Диагноз:</strong> {html.escape(str(item.get('diagnosis') or '-'))}</p>"
f"<p class=\"stack-card-body\"><strong>Что не делать:</strong> {html.escape(str(item.get('stop_doing') or '-'))}</p>"
f"<p class=\"stack-card-body\"><strong>Цель 24ч:</strong> {html.escape(str(item.get('target_state_24h') or '-'))}</p>"
f"<ul class=\"meta-list\">{actions or '<li>Нет действий.</li>'}</ul>"
f"<p class=\"stack-card-action\"><a class=\"inline-link\" href=\"{company_detail_url(company)}\">Карточка компании</a></p>"
"</article>"
)
return f"""<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="300">
<title>1C Recovery Brief</title>
<style>
:root {{
--bg:#f4f1ea; --paper:#fffdf8; --ink:#1c1a17; --muted:#6b655c; --line:#d8d0c4; --accent:#005f73;
--critical:#9b2226; --high:#bb3e03; --medium:#ca6702; --low:#4d7c0f; --none:#687076; --shadow:0 14px 40px rgba(28,26,23,.08);
}}
* {{ box-sizing:border-box; }}
body {{ margin:0; font-family:"IBM Plex Sans","Segoe UI",system-ui,sans-serif; color:var(--ink); background:linear-gradient(180deg,#faf7f2 0%,var(--bg) 100%); }}
.shell {{ max-width:1380px; margin:0 auto; padding:28px 22px 60px; }}
.hero {{ background:linear-gradient(135deg,rgba(116,0,0,.92),rgba(155,34,38,.92)); color:#fff9f9; border-radius:24px; padding:28px 30px; box-shadow:var(--shadow); }}
.hero h1 {{ margin:0 0 12px; font-size:clamp(28px,4vw,42px); line-height:1.05; }}
.hero-meta {{ color:rgba(255,249,249,.82); font-size:14px; }}
.hero-links {{ display:flex; gap:10px; flex-wrap:wrap; margin-top:14px; }}
.hero-links a {{ text-decoration:none; color:#fff9f9; border:1px solid rgba(255,249,249,.28); padding:9px 12px; border-radius:999px; font-size:14px; }}
.grid {{ display:grid; grid-template-columns:repeat(12,minmax(0,1fr)); gap:18px; margin-top:22px; }}
.panel {{ background:var(--paper); border:1px solid var(--line); border-radius:22px; padding:22px; box-shadow:var(--shadow); }}
.span-12 {{ grid-column:span 12; }} .span-6 {{ grid-column:span 6; }}
.stack {{ display:grid; gap:14px; }}
.stack-card {{ border:1px solid var(--line); border-radius:18px; padding:16px 18px; background:#fffdfa; }}
.stack-card-head {{ display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:10px; }}
.stack-card-body,.stack-card-action {{ margin:0 0 10px; line-height:1.55; }}
.badge {{ display:inline-flex; align-items:center; justify-content:center; padding:6px 10px; border-radius:999px; font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:#fff; }}
.badge-critical {{ background:var(--critical); }} .badge-high {{ background:var(--high); }} .badge-medium {{ background:var(--medium); }} .badge-low {{ background:var(--low); }} .badge-none {{ background:var(--none); }}
.inline-link {{ color:var(--accent); text-decoration:none; font-weight:600; }} .inline-link:hover {{ text-decoration:underline; }}
.meta-list {{ margin:0; padding-left:18px; line-height:1.55; }}
@media (max-width:1100px) {{ .span-6 {{ grid-column:span 12; }} }}
@media (max-width:640px) {{ .shell {{ padding:16px 14px 40px; }} .hero {{ padding:22px 18px; }} }}
</style>
</head>
<body>
<main class="shell">
<section class="hero">
<div class="hero-meta">AW-rus · AI Recovery Brief · render mode: {html.escape(str(render_mode))}</div>
<h1>{html.escape(str(recovery.get('headline') or 'Recovery brief недоступен'))}</h1>
<div class="hero-meta">Сформировано: {html.escape(str(generated_at or '-'))}</div>
<nav class="hero-links">
<a href="/manager/brief">Текущий brief</a>
<a href="/manager/changes">Что изменилось</a>
<a href="/manager/trends/weekly">Неделя</a>
<a href="/manager/digest/weekly">Weekly digest</a>
<a href="/manager/problematic?days=7">Проблемные 7д</a>
<a href="/api/1/analytics-1c/manager/recovery/latest">JSON</a>
<a href="/api/1/analytics-1c/manager/recovery/latest.md">Markdown</a>
</nav>
</section>
<section class="grid">
<article class="panel span-6">
<h2>Ситуация</h2>
<ul class="meta-list">{situation_items or '<li>Нет данных.</li>'}</ul>
</article>
<article class="panel span-6">
<h2>Что делать по портфелю</h2>
<ul class="meta-list">{action_items or '<li>Нет данных.</li>'}</ul>
</article>
<article class="panel span-12">
<h2>Компании первой очереди для recovery</h2>
<div class="stack">{''.join(incident_cards) or '<p>Нет recovery-инцидентов.</p>'}</div>
</article>
<article class="panel span-12">
<h2>Ограничения интерпретации</h2>
<ul class="meta-list">{caveat_items or '<li>Нет данных.</li>'}</ul>
</article>
</section>
</main>
</body>
</html>"""
app = FastAPI(title="AW-rus 1C Company Intelligence API", version="1.0.0") app = FastAPI(title="AW-rus 1C Company Intelligence API", version="1.0.0")
@@ -1702,6 +1894,7 @@ def company_summary(counterparty: str, infobase: str | None = None) -> dict[str,
"recent_documents": timeline, "recent_documents": timeline,
} }
payload["priority_context"] = build_company_priority_context(payload, infobase) payload["priority_context"] = build_company_priority_context(payload, infobase)
payload["recovery_context"] = build_company_recovery_context(payload, infobase)
return payload return payload
@@ -1801,6 +1994,19 @@ def manager_weekly_digest_latest_markdown() -> str:
return latest_md.read_text(encoding="utf-8") return latest_md.read_text(encoding="utf-8")
@app.get("/api/1/analytics-1c/manager/recovery/latest")
def manager_recovery_latest() -> dict[str, Any]:
return load_latest_recovery_brief()
@app.get("/api/1/analytics-1c/manager/recovery/latest.md", response_class=PlainTextResponse)
def manager_recovery_latest_markdown() -> str:
latest_md = recovery_brief_state_dir() / "latest.md"
if not latest_md.exists():
raise HTTPException(status_code=404, detail="recovery brief markdown not generated yet")
return latest_md.read_text(encoding="utf-8")
@app.get("/api/1/analytics-1c/companies/problematic") @app.get("/api/1/analytics-1c/companies/problematic")
def problematic_companies_api( def problematic_companies_api(
days: int = Query(default=7, ge=1, le=30), days: int = Query(default=7, ge=1, le=30),
@@ -1830,6 +2036,11 @@ def manager_weekly_digest_view() -> str:
return render_weekly_digest_html(load_latest_weekly_digest()) return render_weekly_digest_html(load_latest_weekly_digest())
@app.get("/manager/recovery", response_class=HTMLResponse)
def manager_recovery_view() -> str:
return render_recovery_brief_html(load_latest_recovery_brief())
@app.get("/manager/briefs", response_class=HTMLResponse) @app.get("/manager/briefs", response_class=HTMLResponse)
def manager_brief_history_view(limit: int = Query(default=40, ge=1, le=200)) -> str: def manager_brief_history_view(limit: int = Query(default=40, ge=1, le=200)) -> str:
return render_brief_history_html(load_brief_history_records(limit)) return render_brief_history_html(load_brief_history_records(limit))
@@ -1860,6 +2071,7 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str |
signals = summary_payload.get("signals") or [] signals = summary_payload.get("signals") or []
recent_documents = summary_payload.get("recent_documents") or [] recent_documents = summary_payload.get("recent_documents") or []
priority_context = summary_payload.get("priority_context") or build_company_priority_context(summary_payload, infobase) priority_context = summary_payload.get("priority_context") or build_company_priority_context(summary_payload, infobase)
recovery_context = summary_payload.get("recovery_context") or build_company_recovery_context(summary_payload, infobase)
title = card.get("counterparty", "Карточка компании") title = card.get("counterparty", "Карточка компании")
subtitle = summary_payload.get("essence", "") subtitle = summary_payload.get("essence", "")
@@ -1904,6 +2116,10 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str |
f"<li>{html.escape(str(item))}</li>" f"<li>{html.escape(str(item))}</li>"
for item in priority_context.get("actions", []) for item in priority_context.get("actions", [])
) )
recovery_action_items = "".join(
f"<li>{html.escape(str(item))}</li>"
for item in recovery_context.get("actions", [])
)
forecast_rows = [] forecast_rows = []
for item in forecasts: for item in forecasts:
@@ -2060,6 +2276,7 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str |
<a href="/manager/changes">Что изменилось</a> <a href="/manager/changes">Что изменилось</a>
<a href="/manager/trends/weekly">Неделя</a> <a href="/manager/trends/weekly">Неделя</a>
<a href="/manager/digest/weekly">Weekly digest</a> <a href="/manager/digest/weekly">Weekly digest</a>
<a href="/manager/recovery">AI recovery</a>
<a href="/manager/briefs">История brief</a> <a href="/manager/briefs">История brief</a>
<a href="/manager/problematic?days=7">Проблемные компании</a> <a href="/manager/problematic?days=7">Проблемные компании</a>
<a href="{html.escape(summary_url)}">JSON summary</a> <a href="{html.escape(summary_url)}">JSON summary</a>
@@ -2103,6 +2320,18 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str |
<ul class="meta-list">{action_items}</ul> <ul class="meta-list">{action_items}</ul>
</article> </article>
<article class="panel span-12">
<h2>AI-план снятия проблемы</h2>
<div class="summary-box summary-box-priority">
<p><strong>Диагноз:</strong> {html.escape(str(recovery_context.get("diagnosis") or "-"))}</p>
<p><strong>Что не делать:</strong> {html.escape(str(recovery_context.get("stop_doing") or "-"))}</p>
<p><strong>Цель 24ч:</strong> {html.escape(str(recovery_context.get("target_state_24h") or "-"))}</p>
<p><strong>Источник:</strong> {html.escape(str(recovery_context.get("confidence") or "-"))}
{f" · сформировано {html.escape(str(recovery_context.get('generated_at')))}" if recovery_context.get("generated_at") else ""}</p>
<ul class="meta-list">{recovery_action_items or '<li>Нет действий.</li>'}</ul>
</div>
</article>
<article class="panel span-6"> <article class="panel span-6">
<h2>Карточка компании</h2> <h2>Карточка компании</h2>
<ul class="meta-list"> <ul class="meta-list">
+385
View File
@@ -0,0 +1,385 @@
#!/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,
p.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 "-")
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": 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())
+22
View File
@@ -0,0 +1,22 @@
Ты готовишь recovery brief для руководителя по проблемам в файловой 1С.
Правила:
- Пиши только по фактам из переданного JSON-контекста.
- Не придумывай данные, причины или последствия, которых нет в контексте.
- Пиши по-русски, коротко, жёстко, управленчески.
- Не упоминай ИИ, Codex, модель, prompt, JSON, ClickHouse, API.
- Если `amount` означает activity score, называй это "активность", а не "деньги" или "выручка".
- Главная задача: не описывать состояние, а предлагать порядок вывода портфеля из operational-перегруза.
- Для top incidents обязательно укажи:
- диагноз;
- что делать;
- что прекратить делать;
- какое состояние должно быть через 24 часа.
- Для `manual`/`alias` явно указывай осторожность, если это влияет на приоритет.
Верни JSON строго по schema.
Контекст:
<context_json>
{{CONTEXT_JSON}}
</context_json>
+101
View File
@@ -0,0 +1,101 @@
{
"type": "object",
"additionalProperties": false,
"required": [
"headline",
"situation",
"portfolio_actions",
"top_incidents",
"caveats"
],
"properties": {
"headline": {
"type": "string",
"minLength": 1,
"maxLength": 180
},
"situation": {
"type": "array",
"minItems": 3,
"maxItems": 6,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 280
}
},
"portfolio_actions": {
"type": "array",
"minItems": 3,
"maxItems": 6,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 280
}
},
"top_incidents": {
"type": "array",
"maxItems": 6,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"company",
"severity",
"diagnosis",
"actions",
"stop_doing",
"target_state_24h"
],
"properties": {
"company": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"severity": {
"type": "string",
"minLength": 1,
"maxLength": 32
},
"diagnosis": {
"type": "string",
"minLength": 1,
"maxLength": 280
},
"actions": {
"type": "array",
"minItems": 2,
"maxItems": 4,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 220
}
},
"stop_doing": {
"type": "string",
"minLength": 1,
"maxLength": 220
},
"target_state_24h": {
"type": "string",
"minLength": 1,
"maxLength": 220
}
}
}
},
"caveats": {
"type": "array",
"minItems": 1,
"maxItems": 5,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 280
}
}
}
}
@@ -352,6 +352,139 @@ class CompanyIntelligenceApiTests(unittest.TestCase):
self.assertIn("Компании первой очереди", html_page) self.assertIn("Компании первой очереди", html_page)
self.assertIn("Что улучшилось за неделю", html_page) self.assertIn("Что улучшилось за неделю", html_page)
def test_build_company_recovery_context_from_latest_recovery(self) -> None:
recovery_payload = {
"generated_at": "2026-05-22T13:00:00+00:00",
"render_mode": "codex",
"recovery": {
"headline": "Recovery test",
"top_incidents": [
{
"company": "ФЕЛИЦТ ГРУПП 2026",
"severity": "critical",
"diagnosis": "Открытые кейсы 6, detections 9, блокировки 2.",
"actions": ["Закрыть минимум 3 кейса.", "Проверить lock-контур."],
"stop_doing": "Не тянуть хвост без владельца.",
"target_state_24h": "Кейсы <= 3 и нет нового прироста.",
}
],
},
}
summary_payload = {
"card": {
"counterparty": "ФЕЛИЦТ ГРУПП 2026",
"infobase": "ФЕЛИЦТ ГРУПП 2026",
"signal_severity": "critical",
"signal_score": 95,
"open_cases_total": 6,
"detections_total": 9,
"active_locks": 2,
"registry_match_mode": "manual",
},
"priority_context": {
"current_priority_tier": "critical",
"current_priority_score": 190,
"current_priority_reason": "рост кейсов +5",
"actions": ["Разобрать кейсы."],
},
}
with tempfile.TemporaryDirectory() as tmp:
state_dir = Path(tmp)
(state_dir / "latest.json").write_text(json.dumps(recovery_payload), encoding="utf-8")
old = os.environ.get("AW_1C_RECOVERY_BRIEF_STATE_DIR")
os.environ["AW_1C_RECOVERY_BRIEF_STATE_DIR"] = str(state_dir)
try:
context = api.build_company_recovery_context(summary_payload, "ФЕЛИЦТ ГРУПП 2026")
self.assertEqual(context["confidence"], "recovery-brief/codex")
self.assertIn("Открытые кейсы 6", context["diagnosis"])
self.assertIn("Закрыть минимум 3 кейса.", context["actions"])
finally:
if old is None:
os.environ.pop("AW_1C_RECOVERY_BRIEF_STATE_DIR", None)
else:
os.environ["AW_1C_RECOVERY_BRIEF_STATE_DIR"] = old
def test_render_recovery_brief_html_and_company_page_block(self) -> None:
recovery_payload = {
"generated_at": "2026-05-22T13:00:00+00:00",
"render_mode": "codex",
"recovery": {
"headline": "Recovery-контур под давлением.",
"situation": ["Кейсы растут.", "Нужен triage."],
"portfolio_actions": ["Сжать фокус до 5 компаний."],
"top_incidents": [
{
"company": "ФЕЛИЦТ ГРУПП 2026",
"severity": "critical",
"diagnosis": "Открытые кейсы 6.",
"actions": ["Закрыть минимум 3 кейса."],
"stop_doing": "Не тянуть хвост без владельца.",
"target_state_24h": "Кейсы <= 3.",
}
],
"caveats": ["Operational severity не равна финансам."],
},
}
html_page = api.render_recovery_brief_html(recovery_payload)
self.assertIn("Recovery-контур под давлением", html_page)
self.assertIn("Компании первой очереди для recovery", html_page)
self.assertIn("ФЕЛИЦТ ГРУПП 2026", html_page)
company_html = api.render_company_detail_html(
{
"essence": "test",
"card": {
"counterparty": "ФЕЛИЦТ ГРУПП 2026",
"company_name": "ФЕЛИЦТ ГРУПП 2026",
"normalized_counterparty": "ФЕЛИЦТ ГРУПП",
"infobase": "ФЕЛИЦТ ГРУПП 2026",
"signal_severity": "critical",
"signal_score": 95,
"amount_7d": 10.0,
"amount_30d": 20.0,
"amount_forecast_30d": 30.0,
"docs_30d": 6,
"open_cases_total": 6,
"detections_total": 9,
"current_status": "busy",
"active_locks": 2,
"days_since_last_activity": 0,
"registry_match_mode": "manual",
"registry_assignee_name": "Иванов",
"registry_inn": "123",
"registry_kpp": "456",
"base_path": "C:/1C",
},
"company_state": {
"current_status": "busy",
"active_locks": 2,
"current_activity_score": 45.0,
"ts": "2026-05-22T13:00:00+00:00",
},
"forecasts": [],
"signals": [],
"recent_documents": [],
"priority_context": {
"current_priority_tier": "critical",
"current_priority_score": 190,
"current_priority_reason": "рост кейсов +5",
"verdict": "Приоритет высокий.",
"evidence": ["Кейсы растут."],
"actions": ["Разобрать кейсы."],
},
"recovery_context": {
"generated_at": "2026-05-22T13:00:00+00:00",
"confidence": "recovery-brief/codex",
"diagnosis": "Открытые кейсы 6.",
"actions": ["Закрыть минимум 3 кейса."],
"stop_doing": "Не тянуть хвост без владельца.",
"target_state_24h": "Кейсы <= 3.",
},
}
)
self.assertIn("AI-план снятия проблемы", company_html)
self.assertIn("Закрыть минимум 3 кейса.", company_html)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -0,0 +1,13 @@
[Unit]
Description=AW-rus 1C Recovery Brief
After=network-online.target aw-1c-manager-brief.service
Wants=network-online.target
[Service]
Type=oneshot
Environment=AW_1C_ROOT=/opt/activitywatch/clickhouse-1c
ExecStart=/opt/activitywatch/clickhouse-1c/ops/run_recovery_brief.sh
TimeoutStartSec=45min
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,11 @@
[Unit]
Description=Run AW-rus 1C recovery brief every 6 hours
[Timer]
OnBootSec=45min
OnUnitActiveSec=6h
Unit=aw-1c-recovery-brief.service
Persistent=true
[Install]
WantedBy=timers.target
+7
View File
@@ -8,6 +8,7 @@ CONFIG="${ROOT}/etl/config.yml"
CH_CONTAINER="${AW_1C_CLICKHOUSE_CONTAINER:-aw-rus-1c-clickhouse}" CH_CONTAINER="${AW_1C_CLICKHOUSE_CONTAINER:-aw-rus-1c-clickhouse}"
LOCK_FILE="${ROOT}/.ingest.lock" LOCK_FILE="${ROOT}/.ingest.lock"
RUN_MANAGER_BRIEF_AFTER_INGEST="${AW_1C_MANAGER_BRIEF_RUN_AFTER_INGEST:-1}" RUN_MANAGER_BRIEF_AFTER_INGEST="${AW_1C_MANAGER_BRIEF_RUN_AFTER_INGEST:-1}"
RUN_RECOVERY_BRIEF_AFTER_INGEST="${AW_1C_RECOVERY_BRIEF_RUN_AFTER_INGEST:-1}"
RUN_PROOFCHECK_AFTER_INGEST="${AW_1C_PROOFCHECK_RUN_AFTER_INGEST:-1}" RUN_PROOFCHECK_AFTER_INGEST="${AW_1C_PROOFCHECK_RUN_AFTER_INGEST:-1}"
if [[ ! -f "${ENV_FILE}" ]]; then if [[ ! -f "${ENV_FILE}" ]]; then
@@ -74,6 +75,12 @@ if [[ "${RUN_MANAGER_BRIEF_AFTER_INGEST}" == "1" ]]; then
fi fi
fi fi
if [[ "${RUN_RECOVERY_BRIEF_AFTER_INGEST}" == "1" ]]; then
if ! "${ROOT}/ops/run_recovery_brief.sh"; then
echo "warning: recovery brief refresh failed after ingest" >&2
fi
fi
if [[ "${RUN_PROOFCHECK_AFTER_INGEST}" == "1" ]]; then if [[ "${RUN_PROOFCHECK_AFTER_INGEST}" == "1" ]]; then
if ! "${ROOT}/ops/check_ingest_freshness.sh"; then if ! "${ROOT}/ops/check_ingest_freshness.sh"; then
echo "warning: freshness proof check failed after ingest" >&2 echo "warning: freshness proof check failed after ingest" >&2
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="${AW_1C_ROOT:-/opt/activitywatch/clickhouse-1c}"
ENV_FILE="${ROOT}/.env"
VENV="${ROOT}/.venv"
LOCK_FILE="${ROOT}/.recovery-brief.lock"
LOCK_WAIT_SEC="${AW_1C_RECOVERY_BRIEF_LOCK_WAIT_SEC:-1800}"
RETRIES="${AW_1C_RECOVERY_BRIEF_RETRIES:-2}"
RETRY_DELAY_SEC="${AW_1C_RECOVERY_BRIEF_RETRY_DELAY_SEC:-20}"
if [[ ! -f "${ENV_FILE}" ]]; then
echo "missing env file: ${ENV_FILE}" >&2
exit 1
fi
if [[ ! -x "${VENV}/bin/python" ]]; then
echo "missing venv python: ${VENV}/bin/python" >&2
exit 1
fi
# shellcheck disable=SC1090
set -a
. "${ENV_FILE}"
set +a
CH_RUNTIME_HOST="${AW_1C_CLICKHOUSE_RUNTIME_HOST:-${CLICKHOUSE_HOST}}"
if [[ "${CH_RUNTIME_HOST}" == "clickhouse" ]]; then
CH_RUNTIME_HOST="127.0.0.1"
fi
exec 9>"${LOCK_FILE}"
if ! flock -w "${LOCK_WAIT_SEC}" 9; then
echo "recovery brief lock wait exceeded: ${LOCK_WAIT_SEC}s" >&2
exit 1
fi
attempt=1
while (( attempt <= RETRIES )); do
if "${VENV}/bin/python" "${ROOT}/ai/generate_recovery_brief.py" \
--host "${CH_RUNTIME_HOST}" \
--port "${CLICKHOUSE_PORT}" \
--user "${CLICKHOUSE_USER}" \
--password "${CLICKHOUSE_PASSWORD}" \
--database "${CLICKHOUSE_DB}"; then
exit 0
fi
if (( attempt == RETRIES )); then
break
fi
echo "recovery brief attempt ${attempt}/${RETRIES} failed, retrying in ${RETRY_DELAY_SEC}s" >&2
sleep "${RETRY_DELAY_SEC}"
attempt=$((attempt + 1))
done
echo "recovery brief failed after ${RETRIES} attempts" >&2
exit 1