feat(1c): add codex recovery brief layer
This commit is contained in:
@@ -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:
|
||||
<a href="{html.escape(delta_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(recovery_html_url)}">AI recovery</a>
|
||||
<a href="{html.escape(problematic_1d_url)}">Проблемные 1д</a>
|
||||
<a href="{html.escape(problematic_7d_url)}">Проблемные 7д</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/changes">Что изменилось</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=7">Проблемные 7д</a>
|
||||
</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/briefs">История brief</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=7">Срез 7д</a>
|
||||
</div>
|
||||
@@ -1200,6 +1294,7 @@ def render_brief_delta_html(payload: dict[str, Any]) -> str:
|
||||
<a href="/manager/briefs">История brief</a>
|
||||
<a href="/manager/trends/weekly">Неделя</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=7">Проблемные 7д</a>
|
||||
</div>
|
||||
@@ -1356,6 +1451,7 @@ def render_weekly_trend_html(report: dict[str, Any]) -> str:
|
||||
<a href="/manager/changes">Что изменилось</a>
|
||||
<a href="/manager/briefs">История brief</a>
|
||||
<a href="/manager/problematic?days=7">Проблемные 7д</a>
|
||||
<a href="/manager/recovery">AI recovery</a>
|
||||
<a href="/manager/digest/weekly">Weekly digest</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1513,6 +1609,7 @@ def render_weekly_digest_html(payload: dict[str, Any]) -> str:
|
||||
<a href="/manager/changes">Что изменилось</a>
|
||||
<a href="/manager/trends/weekly">Неделя</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.md">Markdown</a>
|
||||
</div>
|
||||
@@ -1560,6 +1657,101 @@ def render_weekly_digest_html(payload: dict[str, Any]) -> str:
|
||||
</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")
|
||||
|
||||
|
||||
@@ -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"<li>{html.escape(str(item))}</li>"
|
||||
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 = []
|
||||
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/trends/weekly">Неделя</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/problematic?days=7">Проблемные компании</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>
|
||||
</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">
|
||||
<h2>Карточка компании</h2>
|
||||
<ul class="meta-list">
|
||||
|
||||
@@ -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())
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
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__":
|
||||
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
|
||||
@@ -8,6 +8,7 @@ CONFIG="${ROOT}/etl/config.yml"
|
||||
CH_CONTAINER="${AW_1C_CLICKHOUSE_CONTAINER:-aw-rus-1c-clickhouse}"
|
||||
LOCK_FILE="${ROOT}/.ingest.lock"
|
||||
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}"
|
||||
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
@@ -74,6 +75,12 @@ if [[ "${RUN_MANAGER_BRIEF_AFTER_INGEST}" == "1" ]]; then
|
||||
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 ! "${ROOT}/ops/check_ingest_freshness.sh"; then
|
||||
echo "warning: freshness proof check failed after ingest" >&2
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user