diff --git a/clickhouse-1c/ai/company_intelligence_api.py b/clickhouse-1c/ai/company_intelligence_api.py index 0edb673..fff15fe 100644 --- a/clickhouse-1c/ai/company_intelligence_api.py +++ b/clickhouse-1c/ai/company_intelligence_api.py @@ -2,17 +2,19 @@ from __future__ import annotations import argparse +import html import json import os from datetime import UTC, datetime from decimal import Decimal from pathlib import Path from typing import Any +from urllib.parse import quote import clickhouse_connect import uvicorn from fastapi import FastAPI, HTTPException, Query -from fastapi.responses import PlainTextResponse +from fastapi.responses import HTMLResponse, PlainTextResponse def parse_args() -> argparse.Namespace: @@ -66,6 +68,424 @@ def load_latest_manager_brief() -> dict[str, Any]: return json.loads(latest_path.read_text(encoding="utf-8")) +def grafana_company_dashboard_url() -> str: + return os.getenv( + "AW_1C_MANAGER_BRIEF_GRAFANA_URL", + "http://10.10.10.11:3000/d/1c-file-companies/1c-file-company-intelligence", + ) + + +def fmt_number(value: Any) -> str: + if value is None or value == "": + return "-" + if isinstance(value, float): + return f"{value:,.2f}".replace(",", " ").replace(".", ",") + if isinstance(value, int): + return f"{value:,}".replace(",", " ") + return str(value) + + +def severity_badge(severity: str) -> str: + tone = { + "critical": "critical", + "high": "high", + "medium": "medium", + "low": "low", + "none": "none", + }.get((severity or "").lower(), "none") + return f'{html.escape(severity or "none")}' + + +def render_manager_brief_html(payload: dict[str, Any]) -> str: + brief = payload.get("brief", {}) + context = payload.get("context", {}) + summary = context.get("portfolio_summary", {}) + freshness = context.get("freshness", []) + top_risks = brief.get("top_risks", []) + top_forecasts = brief.get("top_forecasts", []) + actions = brief.get("actions", []) + caveats = brief.get("caveats", []) + render_mode = payload.get("render_mode", "unknown") + generated_at = payload.get("generated_at", "") + history_url = "/api/1/analytics-1c/manager/brief/history" + 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() + + freshness_rows = [] + for item in freshness: + status = "stale" if item.get("stale") else "fresh" + freshness_rows.append( + "" + f"{html.escape(str(item.get('source', '')))}" + f"{html.escape(str(item.get('latest_ts', '-')))}" + f"{html.escape(str(item.get('lag_hours', '-')))}" + f"{'просрочен' if status == 'stale' else 'свежий'}" + "" + ) + + risk_cards = [] + for item in top_risks: + risk_cards.append( + "
" + f"

{html.escape(item.get('company', '-'))}

{severity_badge(item.get('severity', ''))}
" + f"

{html.escape(item.get('reason', '-'))}

" + f"

Действие: {html.escape(item.get('recommended_action', '-'))}

" + "
" + ) + + forecast_cards = [] + for item in top_forecasts: + company = item.get("company", "-") + forecast_cards.append( + "
" + f"

{html.escape(company)}

" + f"

Прогноз 30д: {html.escape(item.get('forecast_30d', '-'))}

" + f"

{html.escape(item.get('interpretation', '-'))}

" + f"Карточка компании" + "
" + ) + + summary_items = "\n".join(f"
  • {html.escape(str(item))}
  • " for item in brief.get("summary", [])) + action_items = "\n".join(f"
  • {html.escape(str(item))}
  • " for item in actions) + caveat_items = "\n".join(f"
  • {html.escape(str(item))}
  • " for item in caveats) + + return f""" + + + + + + 1C Executive Brief + + + +
    +
    +
    +
    +
    AW-rus · 1C Executive Brief · render mode: {html.escape(render_mode)}
    +

    {html.escape(brief.get("headline", "Executive brief недоступен"))}

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

    Краткий комментарий

    +
      + {summary_items} +
    +
    +
    +

    Портфель

    +
    +
    +
    Компаний
    +
    {fmt_number(summary.get("companies_total"))}
    +
    +
    +
    Critical
    +
    {fmt_number(summary.get("critical_total"))}
    +
    +
    +
    Кейсы
    +
    {fmt_number(summary.get("open_cases_total"))}
    +
    +
    +
    Прогноз 30д
    +
    {fmt_number(summary.get("activity_forecast_30d_total"))}
    +
    +
    +
    + +
    +

    Компании риска

    +
    + {''.join(risk_cards) or '

    Нет данных.

    '} +
    +
    + +
    +

    Прогноз активности 30 дней

    +
    + {''.join(forecast_cards) or '

    Нет данных.

    '} +
    +
    + +
    +

    Что проверить руководителю

    +
      + {action_items} +
    +
    + +
    +

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

    +
      + {caveat_items} +
    +
    + +
    +

    Свежесть источников

    + + + + + + + + + + + {''.join(freshness_rows)} + +
    ИсточникПоследняя отметкаОтставание, чСтатус
    +
    +
    +
    + +""" + + app = FastAPI(title="AW-rus 1C Company Intelligence API", version="1.0.0") @@ -280,6 +700,11 @@ def manager_brief_history(limit: int = Query(default=20, ge=1, le=200)) -> dict[ return {"items": items, "count": len(items)} +@app.get("/manager/brief", response_class=HTMLResponse) +def manager_brief_view() -> str: + return render_manager_brief_html(load_latest_manager_brief()) + + if __name__ == "__main__": args = parse_args() uvicorn.run(app, host=args.host, port=args.port) diff --git a/docs/1C_COMPANY_INTELLIGENCE_RU.md b/docs/1C_COMPANY_INTELLIGENCE_RU.md index 1626df8..0d09ca1 100644 --- a/docs/1C_COMPANY_INTELLIGENCE_RU.md +++ b/docs/1C_COMPANY_INTELLIGENCE_RU.md @@ -90,6 +90,16 @@ Endpoints: - `GET /api/1/analytics-1c/manager/brief/latest` - `GET /api/1/analytics-1c/manager/brief/latest.md` - `GET /api/1/analytics-1c/manager/brief/history` +- `GET /manager/brief` + +`/manager/brief` — это human-facing browser page для руководителя: + +- server-rendered HTML; +- без frontend build chain и SPA-зависимостей; +- опирается на уже сгенерированный `latest.json`; +- показывает headline, человеческие комментарии, top risks, top forecasts, + действия и свежесть источников; +- даёт быстрые ссылки на raw JSON/Markdown и в Grafana. ### Executive brief для руководителя