diff --git a/clickhouse-1c/ai/company_intelligence_api.py b/clickhouse-1c/ai/company_intelligence_api.py index ca483be..5ce1ac2 100644 --- a/clickhouse-1c/ai/company_intelligence_api.py +++ b/clickhouse-1c/ai/company_intelligence_api.py @@ -68,6 +68,36 @@ def load_latest_manager_brief() -> dict[str, Any]: 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(): + return [] + items: list[dict[str, Any]] = [] + for path in sorted(history_dir.glob("*.json"), reverse=True)[:limit]: + payload = json.loads(path.read_text(encoding="utf-8")) + items.append( + { + "generated_at": payload.get("generated_at"), + "render_mode": payload.get("render_mode"), + "model": payload.get("model"), + "headline": payload.get("brief", {}).get("headline", ""), + "path": path.name, + "brief": payload.get("brief", {}), + } + ) + return items + + +def load_brief_history_record(name: str) -> dict[str, Any]: + safe_name = Path(name).name + if not safe_name.endswith(".json"): + safe_name += ".json" + path = manager_brief_state_dir() / "history" / safe_name + if not path.exists(): + raise HTTPException(status_code=404, detail="manager brief history record not found") + return json.loads(path.read_text(encoding="utf-8")) + + def grafana_company_dashboard_url() -> str: return os.getenv( "AW_1C_MANAGER_BRIEF_GRAFANA_URL", @@ -103,6 +133,11 @@ def company_detail_url(counterparty: str, infobase: str | None = None) -> str: return base +def manager_brief_history_html_url(name: str) -> str: + safe_name = Path(name).name + return f"/manager/briefs/{quote(safe_name)}" + + def render_manager_brief_html(payload: dict[str, Any]) -> str: brief = payload.get("brief", {}) context = payload.get("context", {}) @@ -115,6 +150,9 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str: render_mode = payload.get("render_mode", "unknown") generated_at = payload.get("generated_at", "") history_url = "/api/1/analytics-1c/manager/brief/history" + history_html_url = "/manager/briefs" + problematic_1d_url = "/manager/problematic?days=1" + problematic_7d_url = "/manager/problematic?days=7" json_url = "/api/1/analytics-1c/manager/brief/latest" md_url = "/api/1/analytics-1c/manager/brief/latest.md" grafana_url = grafana_company_dashboard_url() @@ -409,7 +447,10 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str: @@ -494,6 +535,222 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str: """ +def problematic_companies(days: int = 7, limit: int = 50) -> list[dict[str, Any]]: + client = ch_client() + sql = 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 {int(days)} 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, + p.amount_30d, + p.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.amount_30d DESC, p.counterparty + LIMIT {int(limit)} + """ + return rows_to_dict(client.query(sql)) + + +def render_brief_history_html(items: list[dict[str, Any]]) -> str: + rows = [] + for item in items: + rows.append( + "" + f"{html.escape(str(item.get('generated_at', '-')))}" + f"{html.escape(str(item.get('render_mode', '-')))}" + f"{html.escape(str(item.get('model') or '-'))}" + f"{html.escape(str(item.get('headline') or '-'))}" + f"JSON" + "" + ) + return f""" + + + + + + 1C Brief History + + + +
+
+

История executive brief

+ +
+
+ + + + + + + + + + + + {''.join(rows) or ''} + +
СформированоРежимМодельHeadlineRaw
История пока пуста.
+
+
+ +""" + + +def render_problematic_companies_html(items: list[dict[str, Any]], days: int) -> str: + rows = [] + for item in items: + rows.append( + "" + f"{html.escape(str(item.get('counterparty', '-')))}" + f"{html.escape(str(item.get('normalized_counterparty') or '-'))}" + f"{severity_badge(str(item.get('top_severity') or item.get('signal_severity') or 'none'))}" + f"{fmt_number(item.get('max_score'))}" + f"{fmt_number(item.get('signals_total'))}" + f"{fmt_number(item.get('critical_total'))}" + f"{fmt_number(item.get('amount_30d'))}" + f"{fmt_number(item.get('amount_forecast_30d'))}" + f"{html.escape(str(item.get('top_signal_type') or '-'))}" + f"{html.escape(str(item.get('top_summary') or '-'))}" + "" + ) + return f""" + + + + + + 1C Problem Companies + + + +
+
+

Проблемные компании за {days} {('день' if days == 1 else 'дней')}

+

Список собран по live company signals. В приоритете max score, плотность сигналов и общий вес проблемного контура.

+ +
+
+ + + + + + + + + + + + + + + + + {''.join(rows) or ''} + +
КомпанияНормализацияSeverityMax scoreSignalsCriticalАктивность 30дПрогноз 30дTop signalКомментарий
Нет сигналов за выбранный период.
+
+
+ +""" + + app = FastAPI(title="AW-rus 1C Company Intelligence API", version="1.0.0") @@ -695,29 +952,47 @@ def manager_brief_latest_markdown() -> str: @app.get("/api/1/analytics-1c/manager/brief/history") def manager_brief_history(limit: int = Query(default=20, ge=1, le=200)) -> dict[str, Any]: - history_dir = manager_brief_state_dir() / "history" - if not history_dir.exists(): - return {"items": [], "count": 0} - items: list[dict[str, Any]] = [] - for path in sorted(history_dir.glob("*.json"), reverse=True)[:limit]: - payload = json.loads(path.read_text(encoding="utf-8")) - items.append( - { - "generated_at": payload.get("generated_at"), - "render_mode": payload.get("render_mode"), - "model": payload.get("model"), - "headline": payload.get("brief", {}).get("headline", ""), - "path": path.name, - } - ) + items = load_brief_history_records(limit) return {"items": items, "count": len(items)} +@app.get("/api/1/analytics-1c/manager/brief/history/{name}") +def manager_brief_history_record(name: str) -> dict[str, Any]: + return load_brief_history_record(name) + + +@app.get("/api/1/analytics-1c/companies/problematic") +def problematic_companies_api( + days: int = Query(default=7, ge=1, le=30), + limit: int = Query(default=50, ge=1, le=500), +) -> dict[str, Any]: + items = problematic_companies(days=days, limit=limit) + return {"items": items, "count": len(items), "days": days} + + @app.get("/manager/brief", response_class=HTMLResponse) def manager_brief_view() -> str: return render_manager_brief_html(load_latest_manager_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)) + + +@app.get("/manager/briefs/{name}", response_class=HTMLResponse) +def manager_brief_history_detail_view(name: str) -> str: + return render_manager_brief_html(load_brief_history_record(name)) + + +@app.get("/manager/problematic", response_class=HTMLResponse) +def manager_problematic_companies_view( + days: int = Query(default=7, ge=1, le=30), + limit: int = Query(default=50, ge=1, le=500), +) -> str: + return render_problematic_companies_html(problematic_companies(days=days, limit=limit), days) + + def render_company_detail_html(summary_payload: dict[str, Any], infobase: str | None = None) -> str: card = summary_payload["card"] company_state = summary_payload.get("company_state") or {} @@ -908,6 +1183,8 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str |