From be4609cd25bd38e71b31e81352f9ed54c42de7b2 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Fri, 22 May 2026 16:46:32 +0300 Subject: [PATCH] feat(1c): add codex recovery brief layer --- clickhouse-1c/ai/company_intelligence_api.py | 229 +++++++++++ clickhouse-1c/ai/generate_recovery_brief.py | 385 ++++++++++++++++++ clickhouse-1c/ai/recovery_brief_prompt.md | 22 + clickhouse-1c/ai/recovery_brief_schema.json | 101 +++++ .../ai/test_company_intelligence_api.py | 133 ++++++ .../ops/aw-1c-recovery-brief.service | 13 + clickhouse-1c/ops/aw-1c-recovery-brief.timer | 11 + clickhouse-1c/ops/run_ingest_cycle.sh | 7 + clickhouse-1c/ops/run_recovery_brief.sh | 57 +++ 9 files changed, 958 insertions(+) create mode 100644 clickhouse-1c/ai/generate_recovery_brief.py create mode 100644 clickhouse-1c/ai/recovery_brief_prompt.md create mode 100644 clickhouse-1c/ai/recovery_brief_schema.json create mode 100644 clickhouse-1c/ops/aw-1c-recovery-brief.service create mode 100644 clickhouse-1c/ops/aw-1c-recovery-brief.timer create mode 100644 clickhouse-1c/ops/run_recovery_brief.sh diff --git a/clickhouse-1c/ai/company_intelligence_api.py b/clickhouse-1c/ai/company_intelligence_api.py index dea59d4..27e462f 100644 --- a/clickhouse-1c/ai/company_intelligence_api.py +++ b/clickhouse-1c/ai/company_intelligence_api.py @@ -69,6 +69,14 @@ def weekly_digest_state_dir() -> Path: 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]: latest_path = manager_brief_state_dir() / "latest.json" 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")) +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]]: history_dir = manager_brief_state_dir() / "history" 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: return os.getenv( "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" weekly_html_url = "/manager/trends/weekly" weekly_digest_url = "/manager/digest/weekly" + recovery_html_url = "/manager/recovery" problematic_1d_url = "/manager/problematic?days=1" problematic_7d_url = "/manager/problematic?days=7" json_url = "/api/1/analytics-1c/manager/brief/latest" @@ -749,6 +840,7 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str: Что изменилось Неделя Weekly digest + AI recovery Проблемные 1д Проблемные 7д History API @@ -965,6 +1057,7 @@ def render_brief_history_html(items: list[dict[str, Any]]) -> str: Текущий brief Что изменилось Неделя + AI recovery Проблемные 1д Проблемные 7д @@ -1054,6 +1147,7 @@ def render_problematic_companies_html(items: list[dict[str, Any]], days: int) -> Текущий brief История brief Неделя + AI recovery Срез 1д Срез 7д @@ -1200,6 +1294,7 @@ def render_brief_delta_html(payload: dict[str, Any]) -> str: История brief Неделя Weekly digest + AI recovery Проблемные 1д Проблемные 7д @@ -1356,6 +1451,7 @@ def render_weekly_trend_html(report: dict[str, Any]) -> str: Что изменилось История brief Проблемные 7д + AI recovery Weekly digest @@ -1513,6 +1609,7 @@ def render_weekly_digest_html(payload: dict[str, Any]) -> str: Что изменилось Неделя Проблемные 7д + AI recovery JSON Markdown @@ -1560,6 +1657,101 @@ def render_weekly_digest_html(payload: dict[str, Any]) -> str: """ +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"
  • {html.escape(str(item))}
  • " for item in recovery.get("situation", [])) + action_items = "".join(f"
  • {html.escape(str(item))}
  • " for item in recovery.get("portfolio_actions", [])) + caveat_items = "".join(f"
  • {html.escape(str(item))}
  • " for item in recovery.get("caveats", [])) + incident_cards = [] + for item in recovery.get("top_incidents", []): + company = str(item.get("company") or "-") + actions = "".join(f"
  • {html.escape(str(action))}
  • " for action in item.get("actions", [])) + incident_cards.append( + "
    " + f"

    {html.escape(company)}

    {severity_badge(str(item.get('severity') or 'critical'))}
    " + f"

    Диагноз: {html.escape(str(item.get('diagnosis') or '-'))}

    " + f"

    Что не делать: {html.escape(str(item.get('stop_doing') or '-'))}

    " + f"

    Цель 24ч: {html.escape(str(item.get('target_state_24h') or '-'))}

    " + f"" + f"

    Карточка компании

    " + "
    " + ) + return f""" + + + + + + 1C Recovery Brief + + + +
    +
    +
    AW-rus · AI Recovery Brief · render mode: {html.escape(str(render_mode))}
    +

    {html.escape(str(recovery.get('headline') or 'Recovery brief недоступен'))}

    +
    Сформировано: {html.escape(str(generated_at or '-'))}
    + +
    +
    +
    +

    Ситуация

    +
      {situation_items or '
    • Нет данных.
    • '}
    +
    +
    +

    Что делать по портфелю

    +
      {action_items or '
    • Нет данных.
    • '}
    +
    +
    +

    Компании первой очереди для recovery

    +
    {''.join(incident_cards) or '

    Нет recovery-инцидентов.

    '}
    +
    +
    +

    Ограничения интерпретации

    +
      {caveat_items or '
    • Нет данных.
    • '}
    +
    +
    +
    + +""" + + 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, } payload["priority_context"] = build_company_priority_context(payload, infobase) + payload["recovery_context"] = build_company_recovery_context(payload, infobase) return payload @@ -1801,6 +1994,19 @@ def manager_weekly_digest_latest_markdown() -> str: 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") def problematic_companies_api( 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()) +@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) 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)) @@ -1860,6 +2071,7 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str | signals = summary_payload.get("signals") or [] recent_documents = summary_payload.get("recent_documents") or [] 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", "Карточка компании") subtitle = summary_payload.get("essence", "") @@ -1904,6 +2116,10 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str | f"
  • {html.escape(str(item))}
  • " for item in priority_context.get("actions", []) ) + recovery_action_items = "".join( + f"
  • {html.escape(str(item))}
  • " + for item in recovery_context.get("actions", []) + ) forecast_rows = [] for item in forecasts: @@ -2060,6 +2276,7 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str | Что изменилось Неделя Weekly digest + AI recovery История brief Проблемные компании JSON summary @@ -2103,6 +2320,18 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str | +
    +

    AI-план снятия проблемы

    +
    +

    Диагноз: {html.escape(str(recovery_context.get("diagnosis") or "-"))}

    +

    Что не делать: {html.escape(str(recovery_context.get("stop_doing") or "-"))}

    +

    Цель 24ч: {html.escape(str(recovery_context.get("target_state_24h") or "-"))}

    +

    Источник: {html.escape(str(recovery_context.get("confidence") or "-"))} + {f" · сформировано {html.escape(str(recovery_context.get('generated_at')))}" if recovery_context.get("generated_at") else ""}

    +
      {recovery_action_items or '
    • Нет действий.
    • '}
    +
    +
    +

    Карточка компании