diff --git a/clickhouse-1c/ai/company_intelligence_api.py b/clickhouse-1c/ai/company_intelligence_api.py index f6b1369..ff30e65 100644 --- a/clickhouse-1c/ai/company_intelligence_api.py +++ b/clickhouse-1c/ai/company_intelligence_api.py @@ -61,6 +61,14 @@ def manager_brief_state_dir() -> Path: return root / "state" / "manager-brief" +def weekly_digest_state_dir() -> Path: + root = Path(os.getenv("AW_1C_ROOT", "/opt/activitywatch/clickhouse-1c")) + configured = os.getenv("AW_1C_WEEKLY_DIGEST_STATE_DIR") + if configured: + return Path(configured) + return root / "state" / "weekly-digest" + + def load_latest_manager_brief() -> dict[str, Any]: latest_path = manager_brief_state_dir() / "latest.json" if not latest_path.exists(): @@ -68,6 +76,13 @@ def load_latest_manager_brief() -> dict[str, Any]: return json.loads(latest_path.read_text(encoding="utf-8")) +def load_latest_weekly_digest() -> dict[str, Any]: + latest_path = weekly_digest_state_dir() / "latest.json" + if not latest_path.exists(): + raise HTTPException(status_code=404, detail="weekly digest 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(): @@ -221,6 +236,136 @@ def build_weekly_trend_report(payloads: list[dict[str, Any]], days: int = 7) -> } +def tier_rank(value: str | None) -> int: + return { + "low": 1, + "medium": 2, + "high": 3, + "critical": 4, + }.get((value or "low").lower(), 1) + + +def find_change_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_priority_context(summary_payload: dict[str, Any], infobase: str | None = None) -> dict[str, Any]: + card = summary_payload.get("card") or {} + counterparty = str(card.get("counterparty") or "") + current_infobase = infobase or str(card.get("infobase") or "") + latest_change = None + weekly_change = None + weekly_report = {"top_weekly_changes": []} + + try: + latest_brief = load_latest_manager_brief() + latest_change = find_change_for_company( + extract_delta(latest_brief).get("top_changes", []), + counterparty, + current_infobase or None, + ) + except HTTPException: + latest_brief = None + + try: + weekly_report = build_weekly_trend_report(load_brief_history_payloads(limit=400), days=7) + weekly_change = find_change_for_company( + weekly_report.get("top_weekly_changes", []), + counterparty, + current_infobase or None, + ) + except Exception: + weekly_report = {"top_weekly_changes": []} + + current_tier = "low" + current_score = 0.0 + current_reason = "сильных weekly/delta-триггеров не видно" + if latest_change: + current_tier = str(latest_change.get("priority_tier") or "low") + current_score = float(latest_change.get("priority_score") or 0) + current_reason = str(latest_change.get("priority_reason") or latest_change.get("summary") or current_reason) + elif weekly_change: + current_tier = str(weekly_change.get("priority_tier") or "low") + current_score = float(weekly_change.get("priority_score") or 0) + current_reason = str(weekly_change.get("priority_reason") or weekly_change.get("summary") or current_reason) + else: + severity = str(card.get("signal_severity") or "low") + current_tier = severity if severity in {"critical", "high", "medium", "low"} else "low" + current_score = float(card.get("signal_score") or 0) + if int(card.get("open_cases_total") or 0) > 0 or int(card.get("active_locks") or 0) > 0: + current_reason = "у компании уже есть operational pressure: кейсы/блокировки/сигналы" + + evidence: list[str] = [] + actions: list[str] = [] + if latest_change: + evidence.append(f"Последний запуск: {latest_change.get('summary') or current_reason}") + if int(latest_change.get("open_cases_delta") or 0) > 0: + evidence.append(f"Кейсы выросли на +{int(latest_change.get('open_cases_delta') or 0)}.") + actions.append("Сразу разобрать новые открытые кейсы по компании.") + if int(latest_change.get("detections_delta") or 0) > 0: + evidence.append(f"Detections выросли на +{int(latest_change.get('detections_delta') or 0)}.") + if int(latest_change.get("active_locks_delta") or 0) > 0: + evidence.append(f"Блокировки выросли на +{int(latest_change.get('active_locks_delta') or 0)}.") + actions.append("Проверить, не застряла ли файловая база в busy/lock-контуре.") + if float(latest_change.get("forecast_delta") or 0) < 0: + evidence.append(f"Прогноз 30д просел на {fmt_number(latest_change.get('forecast_delta'))}.") + if weekly_change and weekly_change is not latest_change: + evidence.append(f"За неделю компания вошла в top changes: {weekly_change.get('priority_reason') or weekly_change.get('summary')}.") + if str(card.get("signal_severity") or "none") in {"critical", "high"}: + evidence.append(f"Текущая severity {card.get('signal_severity')} при score {fmt_number(card.get('signal_score'))}.") + if card.get("registry_match_mode") == "manual": + evidence.append("Привязка к реестру manual: интерпретацию владельца и юр. соответствия проверять отдельно.") + if int(card.get("open_cases_total") or 0) > 0: + evidence.append(f"Сейчас открытых кейсов {int(card.get('open_cases_total') or 0)}.") + if int(card.get("detections_total") or 0) > 0: + evidence.append(f"Сейчас active detections {int(card.get('detections_total') or 0)}.") + if int(card.get("active_locks") or 0) > 0: + evidence.append(f"Сейчас активных блокировок {int(card.get('active_locks') or 0)}.") + if int(card.get("days_since_last_activity") or 0) >= 7: + evidence.append(f"Компания молчит уже {int(card.get('days_since_last_activity') or 0)} дн.") + actions.append("Подтвердить, это плановая бизнес-пауза или выпадение из операционного ритма.") + if not actions: + actions.append("Сначала проверить последние сигналы и timeline компании, затем подтверждать бизнес-выводы.") + if int(card.get("open_cases_total") or 0) > 0 and "Сразу разобрать новые открытые кейсы по компании." not in actions: + actions.append("Разобрать открытые кейсы до обсуждения прогноза.") + if int(card.get("active_locks") or 0) > 0 and "Проверить, не застряла ли файловая база в busy/lock-контуре." not in actions: + actions.append("Проверить busy/lock-контур файловой базы.") + + if tier_rank(current_tier) >= tier_rank("high"): + verdict = ( + "Приоритет действительно высокий не из-за общей красноты портфеля, а из-за конкретных operational-сдвигов по этой компании." + ) + elif tier_rank(current_tier) == tier_rank("medium"): + verdict = "Приоритет средний: явный сдвиг есть, но он ещё не выглядит аварийным." + else: + verdict = "Приоритет низкий: компания видна в портфеле, но жёсткого триггера на разбор первой очереди нет." + + return { + "current_priority_tier": current_tier, + "current_priority_score": round(current_score, 2), + "current_priority_reason": current_reason, + "latest_change": latest_change, + "weekly_change": weekly_change, + "weekly_period_start": weekly_report.get("period_start"), + "weekly_period_end": weekly_report.get("period_end"), + "verdict": verdict, + "evidence": evidence[:8], + "actions": actions[:5], + } + + def grafana_company_dashboard_url() -> str: return os.getenv( "AW_1C_MANAGER_BRIEF_GRAFANA_URL", @@ -276,6 +421,7 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str: history_html_url = "/manager/briefs" delta_html_url = "/manager/changes" weekly_html_url = "/manager/trends/weekly" + weekly_digest_url = "/manager/digest/weekly" problematic_1d_url = "/manager/problematic?days=1" problematic_7d_url = "/manager/problematic?days=7" json_url = "/api/1/analytics-1c/manager/brief/latest" @@ -575,6 +721,7 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str: История brief Что изменилось Неделя + Weekly digest Проблемные 1д Проблемные 7д History API @@ -1004,6 +1151,7 @@ def render_brief_delta_html(payload: dict[str, Any]) -> str: Текущий brief История brief Неделя + Weekly digest Проблемные 1д Проблемные 7д @@ -1160,6 +1308,7 @@ def render_weekly_trend_html(report: dict[str, Any]) -> str: Что изменилось История brief Проблемные 7д + Weekly digest
@@ -1213,6 +1362,156 @@ def render_weekly_trend_html(report: dict[str, Any]) -> str: """ +def render_weekly_digest_html(payload: dict[str, Any]) -> str: + digest = payload.get("digest", {}) + context = payload.get("context", {}) + latest = context.get("latest_summary", {}) + top_priorities = digest.get("top_priorities", []) + improvements = digest.get("improvements", []) + actions = digest.get("actions", []) + caveats = digest.get("caveats", []) + summary = digest.get("summary", []) + generated_at = payload.get("generated_at", "") + + stat_cards = [ + ("Компаний", fmt_number(latest.get("companies_total"))), + ("Critical", fmt_number(latest.get("critical_total"))), + ("Busy", fmt_number(latest.get("busy_total"))), + ("Кейсы", fmt_number(latest.get("open_cases_total"))), + ("Detections", fmt_number(latest.get("detections_total"))), + ("Прогноз 30д", fmt_number(latest.get("activity_forecast_30d_total"))), + ] + stat_html = "".join( + f"
{html.escape(label)}
{html.escape(value)}
" + for label, value in stat_cards + ) + + priority_cards = [] + for item in top_priorities: + company = str(item.get("company") or "-") + priority_cards.append( + "
" + f"

{html.escape(company)}

{severity_badge(str(item.get('priority') or 'low'))}
" + f"

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

" + f"

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

" + f"

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

" + "
" + ) + + improvement_rows = [] + for item in improvements: + improvement_rows.append( + "" + f"{html.escape(str(item.get('company') or '-'))}" + f"{html.escape(str(item.get('signal') or '-'))}" + f"{html.escape(str(item.get('meaning') or '-'))}" + "" + ) + + summary_items = "\n".join(f"
  • {html.escape(str(item))}
  • " for item in 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 Weekly Digest + + + +
    +
    +

    {html.escape(str(digest.get('headline') or 'Weekly digest'))}

    +

    Период: {html.escape(str(context.get('period_start') or '-'))} -> {html.escape(str(context.get('period_end') or '-'))}. Сформировано: {html.escape(str(generated_at or '-'))}.

    + +
    + +
    +
    +

    Кратко для руководителя

    +
      {summary_items}
    +
    +
    +

    Срез недели

    +
    {stat_html}
    +
    + +
    +

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

    +
    {''.join(priority_cards) or '

    Нет weekly priorities.

    '}
    +
    + +
    +

    Что делать

    +
      {action_items}
    +
    + +
    +

    Что улучшилось за неделю

    + + + + + + {''.join(improvement_rows) or ''} + +
    КомпанияСигналЧто это значит
    Явных улучшений за неделю не зафиксировано.
    +
    + +
    +

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

    +
      {caveat_items}
    +
    +
    +
    + +""" + + app = FastAPI(title="AW-rus 1C Company Intelligence API", version="1.0.0") @@ -1346,7 +1645,7 @@ def company_summary(counterparty: str, infobase: str | None = None) -> dict[str, f"Компания {counterparty}: за 30 дней событий {card['docs_30d']}, суммарная активность {card['amount_30d']}, " f"прогноз активности на 30 дней {card['amount_forecast_30d']}, риск {card['signal_severity']}." ) - return { + payload = { "essence": essence, "card": card, "company_state": company_state[0] if company_state else None, @@ -1354,6 +1653,8 @@ def company_summary(counterparty: str, infobase: str | None = None) -> dict[str, "signals": signals, "recent_documents": timeline, } + payload["priority_context"] = build_company_priority_context(payload, infobase) + return payload @app.get("/api/1/analytics-1c/companies/{counterparty}/forecast") @@ -1439,6 +1740,19 @@ def manager_weekly_trends(days: int = Query(default=7, ge=2, le=30)) -> dict[str return build_weekly_trend_report(load_brief_history_payloads(limit=400), days=days) +@app.get("/api/1/analytics-1c/manager/digest/weekly/latest") +def manager_weekly_digest_latest() -> dict[str, Any]: + return load_latest_weekly_digest() + + +@app.get("/api/1/analytics-1c/manager/digest/weekly/latest.md", response_class=PlainTextResponse) +def manager_weekly_digest_latest_markdown() -> str: + latest_md = weekly_digest_state_dir() / "latest.md" + if not latest_md.exists(): + raise HTTPException(status_code=404, detail="weekly digest 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), @@ -1463,6 +1777,11 @@ 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/digest/weekly", response_class=HTMLResponse) +def manager_weekly_digest_view() -> str: + return render_weekly_digest_html(load_latest_weekly_digest()) + + @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)) @@ -1492,6 +1811,7 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str | forecasts = summary_payload.get("forecasts") or [] 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) title = card.get("counterparty", "Карточка компании") subtitle = summary_payload.get("essence", "") @@ -1528,6 +1848,15 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str | for label, value in metrics ) + evidence_items = "".join( + f"
  • {html.escape(str(item))}
  • " + for item in priority_context.get("evidence", []) + ) + action_items = "".join( + f"
  • {html.escape(str(item))}
  • " + for item in priority_context.get("actions", []) + ) + forecast_rows = [] for item in forecasts: forecast_rows.append( @@ -1634,6 +1963,10 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str | border: 1px solid var(--line); border-left: 5px solid var(--accent); border-radius: 18px; padding: 16px 18px; background: #fffdfa; line-height: 1.55; }} + .summary-box-priority {{ + border-left-color: var(--critical); + background: #fff8f8; + }} .stats {{ display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }} .stat {{ padding: 16px; border-radius: 18px; background: linear-gradient(180deg, #fff 0%, #f7f4ee 100%); border: 1px solid var(--line); }} .stat-label {{ color: var(--muted); font-size: 13px; margin-bottom: 8px; }} @@ -1678,6 +2011,7 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str | К портфелю Что изменилось Неделя + Weekly digest История brief Проблемные компании JSON summary @@ -1705,6 +2039,22 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str | +
    +

    Почему приоритет высокий именно у этой компании

    +
    +

    {html.escape(str(priority_context.get("verdict") or "-"))}

    +

    Текущий приоритет: {severity_badge(str(priority_context.get("current_priority_tier") or "low"))} + · score {html.escape(fmt_number(priority_context.get("current_priority_score")))} + · причина: {html.escape(str(priority_context.get("current_priority_reason") or "-"))}

    +
      {evidence_items or '
    • Жёстких драйверов приоритета не зафиксировано.
    • '}
    +
    +
    + +
    +

    Что проверить первым действием

    + +
    +

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