From 70d12dca1d885a046e5abddcf79ef01a18a9e0c5 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Fri, 22 May 2026 15:50:22 +0300 Subject: [PATCH] feat(1c): add priority ranking and weekly trend page --- clickhouse-1c/ai/company_intelligence_api.py | 296 +++++++++++++++++- clickhouse-1c/ai/generate_manager_brief.py | 61 +++- .../ai/test_company_intelligence_api.py | 57 ++++ clickhouse-1c/ai/test_manager_brief.py | 2 + 4 files changed, 413 insertions(+), 3 deletions(-) diff --git a/clickhouse-1c/ai/company_intelligence_api.py b/clickhouse-1c/ai/company_intelligence_api.py index 169def0..f6b1369 100644 --- a/clickhouse-1c/ai/company_intelligence_api.py +++ b/clickhouse-1c/ai/company_intelligence_api.py @@ -88,6 +88,18 @@ def load_brief_history_records(limit: int = 20) -> list[dict[str, Any]]: return items +def load_brief_history_payloads(limit: int = 200) -> 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")) + payload["_path"] = path.name + items.append(payload) + return items + + def load_brief_history_record(name: str) -> dict[str, Any]: safe_name = Path(name).name if not safe_name.endswith(".json"): @@ -105,6 +117,110 @@ def extract_delta(payload: dict[str, Any]) -> dict[str, Any]: return {"available": False, "reason": "delta not present in artifact"} +def priority_score_for_change(item: dict[str, Any]) -> float: + if item.get("priority_score") is not None: + try: + return float(item.get("priority_score")) + except (TypeError, ValueError): + return 0.0 + score = 0.0 + score += max(float(item.get("significance") or 0), 0) + score += max(float(item.get("score_delta") or 0), 0) * 0.8 + score += max(float(item.get("open_cases_delta") or 0), 0) * 8 + score += max(float(item.get("detections_delta") or 0), 0) * 5 + score += max(float(item.get("active_locks_delta") or 0), 0) * 10 + return round(score, 2) + + +def build_weekly_trend_report(payloads: list[dict[str, Any]], days: int = 7) -> dict[str, Any]: + now = datetime.now(UTC) + period_start = now.date().toordinal() - max(days - 1, 0) + filtered: list[dict[str, Any]] = [] + for payload in payloads: + generated_at_raw = payload.get("generated_at") + if not generated_at_raw: + continue + try: + generated_at = datetime.fromisoformat(str(generated_at_raw)) + except ValueError: + continue + if generated_at.tzinfo is None: + generated_at = generated_at.replace(tzinfo=UTC) + if generated_at.date().toordinal() < period_start: + continue + payload["_generated_dt"] = generated_at + filtered.append(payload) + + by_day: dict[str, dict[str, Any]] = {} + for payload in filtered: + key = payload["_generated_dt"].date().isoformat() + previous = by_day.get(key) + if previous is None or payload["_generated_dt"] > previous["_generated_dt"]: + by_day[key] = payload + + daily_rows: list[dict[str, Any]] = [] + previous_summary: dict[str, Any] | None = None + for key in sorted(by_day.keys()): + payload = by_day[key] + summary = payload.get("context", {}).get("portfolio_summary", {}) + row = { + "date": key, + "generated_at": payload.get("generated_at"), + "companies_total": int(summary.get("companies_total", 0) or 0), + "critical_total": int(summary.get("critical_total", 0) or 0), + "high_total": int(summary.get("high_total", 0) or 0), + "busy_total": int(summary.get("busy_total", 0) or 0), + "open_cases_total": int(summary.get("open_cases_total", 0) or 0), + "detections_total": int(summary.get("detections_total", 0) or 0), + "activity_30d_total": float(summary.get("activity_30d_total", 0) or 0), + "activity_forecast_30d_total": float(summary.get("activity_forecast_30d_total", 0) or 0), + } + if previous_summary: + row["critical_delta_vs_prev_day"] = row["critical_total"] - int(previous_summary.get("critical_total", 0) or 0) + row["busy_delta_vs_prev_day"] = row["busy_total"] - int(previous_summary.get("busy_total", 0) or 0) + row["open_cases_delta_vs_prev_day"] = row["open_cases_total"] - int(previous_summary.get("open_cases_total", 0) or 0) + row["detections_delta_vs_prev_day"] = row["detections_total"] - int(previous_summary.get("detections_total", 0) or 0) + else: + row["critical_delta_vs_prev_day"] = 0 + row["busy_delta_vs_prev_day"] = 0 + row["open_cases_delta_vs_prev_day"] = 0 + row["detections_delta_vs_prev_day"] = 0 + daily_rows.append(row) + previous_summary = summary + + weekly_changes: dict[tuple[str, str], dict[str, Any]] = {} + for payload in filtered: + delta = extract_delta(payload) + for item in delta.get("top_changes", []): + key = (str(item.get("infobase") or ""), str(item.get("company") or "")) + candidate = dict(item) + candidate["generated_at"] = payload.get("generated_at") + candidate["priority_score"] = priority_score_for_change(candidate) + existing = weekly_changes.get(key) + if existing is None or float(candidate["priority_score"]) > float(existing.get("priority_score") or 0): + weekly_changes[key] = candidate + + weekly_top_changes = sorted( + weekly_changes.values(), + key=lambda item: ( + float(item.get("priority_score") or 0), + float(item.get("significance") or 0), + int(item.get("open_cases_delta") or 0), + ), + reverse=True, + )[:20] + + latest = daily_rows[-1] if daily_rows else None + return { + "days": days, + "period_start": datetime.fromordinal(period_start).date().isoformat(), + "period_end": now.date().isoformat(), + "daily": daily_rows, + "latest": latest, + "top_weekly_changes": weekly_top_changes, + } + + def grafana_company_dashboard_url() -> str: return os.getenv( "AW_1C_MANAGER_BRIEF_GRAFANA_URL", @@ -159,6 +275,7 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str: history_url = "/api/1/analytics-1c/manager/brief/history" history_html_url = "/manager/briefs" delta_html_url = "/manager/changes" + weekly_html_url = "/manager/trends/weekly" problematic_1d_url = "/manager/problematic?days=1" problematic_7d_url = "/manager/problematic?days=7" json_url = "/api/1/analytics-1c/manager/brief/latest" @@ -457,6 +574,7 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str: Markdown История brief Что изменилось + Неделя Проблемные 1д Проблемные 7д History API @@ -651,6 +769,7 @@ def render_brief_history_html(items: list[dict[str, Any]]) -> str: @@ -739,6 +858,7 @@ def render_problematic_companies_html(items: list[dict[str, Any]], days: int) -> @@ -808,20 +928,30 @@ def render_brief_delta_html(payload: dict[str, Any]) -> str: f"
{html.escape(label)}
{html.escape(value)}
" for label, value in stat_cards ) + tier_labels = { + "critical": "Критический", + "high": "Высокий", + "medium": "Средний", + "low": "Низкий", + } rows = [] for item in top_changes: infobase = item.get("infobase") counterparty = item.get("company") + priority_tier = str(item.get("priority_tier") or "low") rows.append( "" f"{html.escape(str(counterparty or '-'))}" + f"{html.escape(tier_labels.get(priority_tier, priority_tier))}" + f"{delta_value(item.get('priority_score', 0))}" f"{html.escape(str(item.get('change_type') or '-'))}" f"{html.escape(str(item.get('severity_before') or '-'))} -> {html.escape(str(item.get('severity_after') or '-'))}" f"{delta_value(item.get('score_delta', 0))}" f"{delta_value(item.get('open_cases_delta', 0))}" f"{delta_value(item.get('active_locks_delta', 0))}" f"{delta_value(item.get('forecast_delta', 0))}" + f"{html.escape(str(item.get('priority_reason') or '-'))}" f"{html.escape(str(item.get('summary') or '-'))}" "" ) @@ -873,6 +1003,7 @@ def render_brief_delta_html(payload: dict[str, Any]) -> str: @@ -910,17 +1041,20 @@ def render_brief_delta_html(payload: dict[str, Any]) -> str: Компания + Приоритет + Score Тип Severity Score Δ Cases Δ Locks Δ Forecast Δ + Причина приоритета Комментарий - {''.join(rows) or 'Нет выраженных изменений.'} + {''.join(rows) or 'Нет выраженных изменений.'} @@ -930,6 +1064,155 @@ def render_brief_delta_html(payload: dict[str, Any]) -> str: """ +def render_weekly_trend_html(report: dict[str, Any]) -> str: + daily = report.get("daily", []) + latest = report.get("latest") or {} + top_weekly_changes = report.get("top_weekly_changes", []) + + def value(v: Any) -> str: + return fmt_number(v) + + trend_rows = [] + for item in daily: + trend_rows.append( + "" + f"{html.escape(str(item.get('date', '-')))}" + f"{value(item.get('companies_total'))}" + f"{value(item.get('critical_total'))}" + f"{value(item.get('busy_total'))}" + f"{value(item.get('open_cases_total'))}" + f"{value(item.get('detections_total'))}" + f"{value(item.get('activity_30d_total'))}" + f"{value(item.get('activity_forecast_30d_total'))}" + f"{value(item.get('critical_delta_vs_prev_day'))}" + f"{value(item.get('open_cases_delta_vs_prev_day'))}" + "" + ) + + change_rows = [] + for item in top_weekly_changes: + infobase = item.get("infobase") + company = item.get("company") + change_rows.append( + "" + f"{html.escape(str(company or '-'))}" + f"{html.escape(str(item.get('priority_tier') or '-'))}" + f"{value(item.get('priority_score'))}" + f"{html.escape(str(item.get('change_type') or '-'))}" + f"{value(item.get('open_cases_delta'))}" + f"{value(item.get('active_locks_delta'))}" + f"{value(item.get('forecast_delta'))}" + f"{html.escape(str(item.get('priority_reason') or '-'))}" + "" + ) + + stat_cards = [ + ("Компаний", value(latest.get("companies_total"))), + ("Critical", value(latest.get("critical_total"))), + ("Busy", value(latest.get("busy_total"))), + ("Кейсы", value(latest.get("open_cases_total"))), + ("Detections", value(latest.get("detections_total"))), + ("Прогноз 30д", value(latest.get("activity_forecast_30d_total"))), + ] + stat_html = "".join( + f"
{html.escape(label)}
{html.escape(val)}
" + for label, val in stat_cards + ) + + return f""" + + + + + + 1C Weekly Trends + + + +
+
+

Недельный тренд портфеля

+

Период: {html.escape(str(report.get('period_start', '-')))} -> {html.escape(str(report.get('period_end', '-')))}. Страница показывает тренд по истории executive brief и недельный рейтинг проблемных компаний.

+ +
+
+

Текущее состояние по последнему дневному срезу

+
{stat_html}
+
+
+

Дневной тренд

+ + + + + + + + + + + + + + + + + {''.join(trend_rows) or ''} + +
ДатаКомпанийCriticalBusyКейсыDetectionsАктивность 30дПрогноз 30дCritical ΔCases Δ
Недостаточно истории для недельного тренда.
+
+
+

Недельный рейтинг приоритетов

+ + + + + + + + + + + + + + + {''.join(change_rows) or ''} + +
КомпанияПриоритетPriority scoreТипCases ΔLocks ΔForecast ΔПричина
За выбранный период заметных изменений нет.
+
+
+ +""" + + app = FastAPI(title="AW-rus 1C Company Intelligence API", version="1.0.0") @@ -1151,6 +1434,11 @@ def manager_brief_delta_latest() -> dict[str, Any]: } +@app.get("/api/1/analytics-1c/manager/trends/weekly") +def manager_weekly_trends(days: int = Query(default=7, ge=2, le=30)) -> dict[str, Any]: + return build_weekly_trend_report(load_brief_history_payloads(limit=400), days=days) + + @app.get("/api/1/analytics-1c/companies/problematic") def problematic_companies_api( days: int = Query(default=7, ge=1, le=30), @@ -1170,6 +1458,11 @@ def manager_brief_delta_view() -> str: return render_brief_delta_html(load_latest_manager_brief()) +@app.get("/manager/trends/weekly", response_class=HTMLResponse) +def manager_weekly_trends_view(days: int = Query(default=7, ge=2, le=30)) -> str: + return render_weekly_trend_html(build_weekly_trend_report(load_brief_history_payloads(limit=400), days=days)) + + @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)) @@ -1384,6 +1677,7 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str |