From ee4eea7e1a92fcc8d850f992b0c367e65ad6450c Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Fri, 22 May 2026 16:03:04 +0300 Subject: [PATCH] feat(1c): add weekly digest and hard priority pages --- clickhouse-1c/ai/company_intelligence_api.py | 352 +++++++++++++++++- clickhouse-1c/ai/generate_weekly_digest.py | 347 +++++++++++++++++ .../ai/test_company_intelligence_api.py | 113 ++++++ clickhouse-1c/ai/weekly_digest_prompt.md | 23 ++ clickhouse-1c/ai/weekly_digest_schema.json | 115 ++++++ clickhouse-1c/ops/aw-1c-weekly-digest.service | 12 + clickhouse-1c/ops/aw-1c-weekly-digest.timer | 10 + clickhouse-1c/ops/run_weekly_digest.sh | 23 ++ 8 files changed, 994 insertions(+), 1 deletion(-) create mode 100644 clickhouse-1c/ai/generate_weekly_digest.py create mode 100644 clickhouse-1c/ai/weekly_digest_prompt.md create mode 100644 clickhouse-1c/ai/weekly_digest_schema.json create mode 100644 clickhouse-1c/ops/aw-1c-weekly-digest.service create mode 100644 clickhouse-1c/ops/aw-1c-weekly-digest.timer create mode 100644 clickhouse-1c/ops/run_weekly_digest.sh 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 '
    • Жёстких драйверов приоритета не зафиксировано.
    • '}
    +
    +
    + +
    +

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

    +
      {action_items}
    +
    +

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

      diff --git a/clickhouse-1c/ai/generate_weekly_digest.py b/clickhouse-1c/ai/generate_weekly_digest.py new file mode 100644 index 0000000..25f9282 --- /dev/null +++ b/clickhouse-1c/ai/generate_weekly_digest.py @@ -0,0 +1,347 @@ +#!/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 pathlib import Path +from typing import Any + +from company_intelligence_api import build_weekly_trend_report, manager_brief_state_dir + +ROOT = Path(__file__).resolve().parents[1] +PROMPT_PATH = ROOT / "ai" / "weekly_digest_prompt.md" +SCHEMA_PATH = ROOT / "ai" / "weekly_digest_schema.json" + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="Generate weekly executive digest for analytics_1c") + p.add_argument( + "--brief-state-dir", + default=os.getenv("AW_1C_MANAGER_BRIEF_STATE_DIR", str(manager_brief_state_dir())), + ) + p.add_argument( + "--state-dir", + default=os.getenv("AW_1C_WEEKLY_DIGEST_STATE_DIR", str(ROOT / "state" / "weekly-digest")), + ) + 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( + "--days", + type=int, + default=int(os.getenv("AW_1C_WEEKLY_DIGEST_DAYS", "7")), + ) + return p.parse_args() + + +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_latest_brief(brief_state_dir: Path) -> dict[str, Any]: + latest_path = brief_state_dir / "latest.json" + if not latest_path.exists(): + raise RuntimeError(f"missing latest manager brief: {latest_path}") + return json.loads(latest_path.read_text(encoding="utf-8")) + + +def load_brief_history_payloads(brief_state_dir: Path, limit: int = 400) -> list[dict[str, Any]]: + history_dir = 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 classify_improvements(payloads: list[dict[str, Any]], limit: int = 5) -> list[dict[str, Any]]: + improvements: dict[tuple[str, str], dict[str, Any]] = {} + for payload in payloads: + delta = payload.get("context", {}).get("delta") or {} + generated_at = payload.get("generated_at") + for item in delta.get("top_changes", []): + change_type = str(item.get("change_type") or "") + score_delta = float(item.get("score_delta") or 0) + open_cases_delta = int(item.get("open_cases_delta") or 0) + forecast_delta = float(item.get("forecast_delta") or 0) + improved = False + signal = "" + if change_type == "severity_down": + improved = True + signal = f"severity снизилась {item.get('severity_before')} -> {item.get('severity_after')}" + elif change_type == "forecast_growth" and forecast_delta > 0: + improved = True + signal = f"прогноз 30д вырос на {round(forecast_delta, 2)}" + elif score_delta < 0 or open_cases_delta < 0: + improved = True + signal = ( + f"score Δ {int(score_delta)}" + if score_delta < 0 + else f"open cases Δ {open_cases_delta}" + ) + if not improved: + continue + key = (str(item.get("infobase") or ""), str(item.get("company") or "")) + candidate = { + "company": item.get("company"), + "infobase": item.get("infobase"), + "signal": signal, + "meaning": str(item.get("summary") or "Нагрузка по компании ослабла."), + "generated_at": generated_at, + "severity_after": item.get("severity_after"), + } + existing = improvements.get(key) + if existing is None: + improvements[key] = candidate + return list(improvements.values())[:limit] + + +def build_context(brief_state_dir: Path, days: int) -> dict[str, Any]: + latest_brief = load_latest_brief(brief_state_dir) + history_payloads = load_brief_history_payloads(brief_state_dir, limit=400) + weekly_report = build_weekly_trend_report(history_payloads, days=days) + latest_context = latest_brief.get("context", {}) + latest_summary = latest_context.get("portfolio_summary", {}) + daily = weekly_report.get("daily", []) + first_day = daily[0] if daily else {} + last_day = daily[-1] if daily else {} + week_delta = { + "critical_total_delta": int(last_day.get("critical_total", 0) or 0) - int(first_day.get("critical_total", 0) or 0), + "busy_total_delta": int(last_day.get("busy_total", 0) or 0) - int(first_day.get("busy_total", 0) or 0), + "open_cases_total_delta": int(last_day.get("open_cases_total", 0) or 0) - int(first_day.get("open_cases_total", 0) or 0), + "detections_total_delta": int(last_day.get("detections_total", 0) or 0) - int(first_day.get("detections_total", 0) or 0), + "activity_30d_total_delta": round(float(last_day.get("activity_30d_total", 0) or 0) - float(first_day.get("activity_30d_total", 0) or 0), 2), + "activity_forecast_30d_total_delta": round(float(last_day.get("activity_forecast_30d_total", 0) or 0) - float(first_day.get("activity_forecast_30d_total", 0) or 0), 2), + } + return { + "generated_at": datetime.now(UTC).isoformat(), + "period_start": weekly_report.get("period_start"), + "period_end": weekly_report.get("period_end"), + "days": days, + "latest_summary": latest_summary, + "freshness": latest_context.get("freshness", []), + "top_weekly_changes": weekly_report.get("top_weekly_changes", []), + "daily": daily, + "week_delta": week_delta, + "improvements": classify_improvements(history_payloads), + "latest_brief_headline": latest_brief.get("brief", {}).get("headline", ""), + } + + +def render_deterministic_digest(context: dict[str, Any]) -> dict[str, Any]: + latest = context.get("latest_summary", {}) + week_delta = context.get("week_delta", {}) + top_changes = context.get("top_weekly_changes", []) + improvements = context.get("improvements", []) + stale_sources = [item["source"] for item in context.get("freshness", []) if item.get("stale")] + + headline = ( + f"Неделя: critical {latest.get('critical_total', 0)}, busy {latest.get('busy_total', 0)}, " + f"кейсы {latest.get('open_cases_total', 0)}." + ) + summary = [ + f"За неделю: critical {week_delta.get('critical_total_delta', 0):+d}, busy {week_delta.get('busy_total_delta', 0):+d}, кейсы {week_delta.get('open_cases_total_delta', 0):+d}, detections {week_delta.get('detections_total_delta', 0):+d}.", + f"Текущий портфель: компаний {latest.get('companies_total', 0)}, direct {latest.get('direct_total', 0)}, alias {latest.get('alias_total', 0)}, manual {latest.get('manual_total', 0)}.", + f"Активность 30д {latest.get('activity_30d_total', 0)}, прогноз 30д {latest.get('activity_forecast_30d_total', 0)}.", + ] + if stale_sources: + summary.append(f"Есть просроченные источники: {', '.join(stale_sources)}.") + else: + summary.append("Источник данных по неделе свежий, контур не выглядит просроченным.") + + top_priorities = [] + for item in top_changes[:5]: + top_priorities.append( + { + "company": str(item.get("company") or "-"), + "priority": str(item.get("priority_tier") or "low"), + "reason": str(item.get("priority_reason") or item.get("summary") or "-"), + "recommended_action": ( + "Проверить открытые кейсы, detections и фактическую занятость базы." + if int(item.get("open_cases_delta") or 0) > 0 or int(item.get("active_locks_delta") or 0) > 0 + else "Проверить причину weekly-сдвига и подтвердить, что это не накопленный operational шум." + ), + } + ) + + actions = [ + "Сначала разбирать weekly priority critical/high, а не весь красный портфель подряд.", + "Для компаний с ростом кейсов и блокировок подтвердить, это operational перегрузка или реальный бизнес-сбой.", + "По manual-match компаниям не делать жёстких выводов без проверки реестровой привязки.", + ] + if top_priorities: + actions.insert(0, f"Приоритет недели: {', '.join(item['company'] for item in top_priorities[:3])}.") + + caveats = [ + "Активность здесь operational-driven и не равна деньгам или выручке.", + "Weekly приоритет строится по изменениям сигналов, кейсов, блокировок и прогноза, а не по бухгалтерскому результату.", + ] + if latest.get("manual_total", 0): + caveats.append("Manual-match компании требуют осторожности при юридической трактовке соответствия реестру.") + + return { + "headline": headline, + "summary": summary[:7], + "top_priorities": top_priorities, + "improvements": improvements[:5], + "actions": actions[:6], + "caveats": caveats[:5], + } + + +def render_markdown(payload: dict[str, Any], generated_at: str) -> str: + lines = [ + "# Weekly Executive Digest 1C", + "", + f"_Сформировано: {generated_at}_", + "", + "## Заголовок", + payload["headline"], + "", + "## Кратко", + ] + for item in payload["summary"]: + lines.append(f"- {item}") + lines.extend(["", "## Приоритет недели"]) + for idx, item in enumerate(payload["top_priorities"], start=1): + lines.append(f"{idx}. {item['company']} [{item['priority']}] — {item['reason']} Действие: {item['recommended_action']}") + lines.extend(["", "## Что улучшилось"]) + for idx, item in enumerate(payload["improvements"], start=1): + lines.append(f"{idx}. {item['company']} — {item['signal']}. {item['meaning']}") + lines.extend(["", "## Рекомендуемые действия"]) + for item in payload["actions"]: + lines.append(f"- {item}") + 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-weekly-digest-", 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 main() -> int: + args = parse_args() + state_dir = Path(args.state_dir) + brief_state_dir = Path(args.brief_state_dir) + context = build_context(brief_state_dir, args.days) + prompt = build_prompt(context) + + render_mode = "deterministic" + model = "deterministic" + digest = render_deterministic_digest(context) + codex_stdout = "" + + rc, stdout_stderr, reply = run_codex(prompt, args) + codex_stdout = stdout_stderr + if rc == 0 and reply: + try: + digest = json.loads(reply) + render_mode = "codex" + model = args.model + except json.JSONDecodeError: + pass + + generated_at = datetime.now(UTC).isoformat() + markdown = render_markdown(digest, generated_at) + payload = { + "generated_at": generated_at, + "render_mode": render_mode, + "model": model, + "context": context, + "digest": digest, + "markdown": markdown, + "codex_stdout": codex_stdout, + } + + latest_json = state_dir / "latest.json" + latest_md = state_dir / "latest.md" + history_json = state_dir / "history" / f"{generated_at.replace(':', '').replace('-', '').replace('+00:00', 'Z')}.json" + history_md = state_dir / "history" / f"{generated_at.replace(':', '').replace('-', '').replace('+00:00', 'Z')}.md" + + write_json(latest_json, payload) + write_text(latest_md, markdown) + write_json(history_json, payload) + write_text(history_md, markdown) + print(json.dumps({"status": "ok", "generated_at": generated_at, "render_mode": render_mode}, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/clickhouse-1c/ai/test_company_intelligence_api.py b/clickhouse-1c/ai/test_company_intelligence_api.py index 4f79109..edac27e 100644 --- a/clickhouse-1c/ai/test_company_intelligence_api.py +++ b/clickhouse-1c/ai/test_company_intelligence_api.py @@ -56,6 +56,77 @@ import company_intelligence_api as api class CompanyIntelligenceApiTests(unittest.TestCase): + def test_build_company_priority_context(self) -> None: + latest_payload = { + "generated_at": "2026-05-22T12:00:00+00:00", + "context": { + "delta": { + "top_changes": [ + { + "infobase": "ФЕЛИЦТ ГРУПП 2026", + "company": "ФЕЛИЦТ ГРУПП 2026", + "priority_tier": "critical", + "priority_score": 190, + "priority_reason": "рост кейсов +5, рост блокировок +2", + "summary": "Открытых кейсов стало больше: 1 -> 6.", + "open_cases_delta": 5, + "active_locks_delta": 2, + "detections_delta": 4, + "forecast_delta": -22.0, + } + ] + } + }, + "brief": {"headline": "test"}, + } + history_payload = { + "generated_at": "2026-05-22T12:00:00+00:00", + "context": { + "portfolio_summary": { + "companies_total": 41, + "critical_total": 39, + "high_total": 2, + "busy_total": 40, + "open_cases_total": 100, + "detections_total": 100, + "activity_30d_total": 1000.0, + "activity_forecast_30d_total": 2000.0, + }, + "delta": latest_payload["context"]["delta"], + }, + } + summary_payload = { + "card": { + "counterparty": "ФЕЛИЦТ ГРУПП 2026", + "infobase": "ФЕЛИЦТ ГРУПП 2026", + "signal_severity": "critical", + "signal_score": 95, + "open_cases_total": 6, + "detections_total": 9, + "active_locks": 2, + "days_since_last_activity": 0, + "registry_match_mode": "manual", + } + } + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) + history_dir = state_dir / "history" + history_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "latest.json").write_text(json.dumps(latest_payload), encoding="utf-8") + (history_dir / "20260522T120000Z.json").write_text(json.dumps(history_payload), encoding="utf-8") + old = os.environ.get("AW_1C_MANAGER_BRIEF_STATE_DIR") + os.environ["AW_1C_MANAGER_BRIEF_STATE_DIR"] = str(state_dir) + try: + context = api.build_company_priority_context(summary_payload, "ФЕЛИЦТ ГРУПП 2026") + self.assertEqual(context["current_priority_tier"], "critical") + self.assertIn("рост кейсов", context["current_priority_reason"]) + self.assertTrue(any("manual" in item.lower() for item in context["evidence"])) + finally: + if old is None: + os.environ.pop("AW_1C_MANAGER_BRIEF_STATE_DIR", None) + else: + os.environ["AW_1C_MANAGER_BRIEF_STATE_DIR"] = old + def test_render_problematic_companies_html(self) -> None: html_page = api.render_problematic_companies_html( [ @@ -205,6 +276,48 @@ class CompanyIntelligenceApiTests(unittest.TestCase): self.assertIn("Недельный рейтинг приоритетов", html_page) self.assertIn("ФЕЛИЦТ ГРУПП 2026", html_page) + def test_render_weekly_digest_html(self) -> None: + payload = { + "generated_at": "2026-05-22T12:00:00+00:00", + "context": { + "period_start": "2026-05-16", + "period_end": "2026-05-22", + "latest_summary": { + "companies_total": 41, + "critical_total": 39, + "busy_total": 40, + "open_cases_total": 100, + "detections_total": 110, + "activity_forecast_30d_total": 2500.0, + }, + }, + "digest": { + "headline": "Неделя тяжёлая: кейсы и busy растут.", + "summary": ["critical +2", "кейсы +15", "manual-match не равен юр. факту"], + "top_priorities": [ + { + "company": "ФЕЛИЦТ ГРУПП 2026", + "priority": "critical", + "reason": "рост кейсов +5, рост блокировок +2", + "recommended_action": "Сразу разобрать новые открытые кейсы.", + } + ], + "improvements": [ + { + "company": "СЕРДИТОВ АНДРЕЙ 2026", + "signal": "severity снизилась high -> medium", + "meaning": "Нагрузка ослабла.", + } + ], + "actions": ["Разобрать top priority компании."], + "caveats": ["Активность не равна выручке."], + }, + } + html_page = api.render_weekly_digest_html(payload) + self.assertIn("Неделя тяжёлая", html_page) + self.assertIn("Компании первой очереди", html_page) + self.assertIn("Что улучшилось за неделю", html_page) + if __name__ == "__main__": unittest.main() diff --git a/clickhouse-1c/ai/weekly_digest_prompt.md b/clickhouse-1c/ai/weekly_digest_prompt.md new file mode 100644 index 0000000..0553062 --- /dev/null +++ b/clickhouse-1c/ai/weekly_digest_prompt.md @@ -0,0 +1,23 @@ +Ты готовишь еженедельный executive digest для руководителя по портфелю компаний в файловой 1С. + +Правила: +- Пиши только по фактам из переданного JSON-контекста. +- Не придумывай данные и не достраивай причины, которых нет в контексте. +- Пиши по-русски, коротко, жёстко и управленчески. +- Не упоминай ИИ, Codex, модель, prompt, JSON, ClickHouse, API. +- Если `amount` означает activity score, называй это "активность", а не "выручка" или "деньги". +- Если почти весь портфель high/critical, явно говори, что severity сейчас operational-driven и не равна финансовому кризису. +- Для `registry_match_mode=manual` не делай сильных юридических выводов. +- Выделяй: + - что ухудшилось за неделю; + - что улучшилось; + - какие компании действительно приоритетны; + - что руководителю проверить в первую очередь. +- Не дублируй сырые таблицы; дай управленческий смысл. + +Верни JSON строго по schema. + +Контекст: + +{{CONTEXT_JSON}} + diff --git a/clickhouse-1c/ai/weekly_digest_schema.json b/clickhouse-1c/ai/weekly_digest_schema.json new file mode 100644 index 0000000..36016e3 --- /dev/null +++ b/clickhouse-1c/ai/weekly_digest_schema.json @@ -0,0 +1,115 @@ +{ + "type": "object", + "additionalProperties": false, + "required": [ + "headline", + "summary", + "top_priorities", + "improvements", + "actions", + "caveats" + ], + "properties": { + "headline": { + "type": "string", + "minLength": 1, + "maxLength": 180 + }, + "summary": { + "type": "array", + "minItems": 3, + "maxItems": 7, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 280 + } + }, + "top_priorities": { + "type": "array", + "maxItems": 5, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "company", + "priority", + "reason", + "recommended_action" + ], + "properties": { + "company": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "priority": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 280 + }, + "recommended_action": { + "type": "string", + "minLength": 1, + "maxLength": 280 + } + } + } + }, + "improvements": { + "type": "array", + "maxItems": 5, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "company", + "signal", + "meaning" + ], + "properties": { + "company": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "signal": { + "type": "string", + "minLength": 1, + "maxLength": 280 + }, + "meaning": { + "type": "string", + "minLength": 1, + "maxLength": 280 + } + } + } + }, + "actions": { + "type": "array", + "minItems": 2, + "maxItems": 6, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 280 + } + }, + "caveats": { + "type": "array", + "minItems": 1, + "maxItems": 5, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 280 + } + } + } +} diff --git a/clickhouse-1c/ops/aw-1c-weekly-digest.service b/clickhouse-1c/ops/aw-1c-weekly-digest.service new file mode 100644 index 0000000..e50ca17 --- /dev/null +++ b/clickhouse-1c/ops/aw-1c-weekly-digest.service @@ -0,0 +1,12 @@ +[Unit] +Description=AW-rus 1C Weekly Executive Digest +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_weekly_digest.sh + +[Install] +WantedBy=multi-user.target diff --git a/clickhouse-1c/ops/aw-1c-weekly-digest.timer b/clickhouse-1c/ops/aw-1c-weekly-digest.timer new file mode 100644 index 0000000..40dc849 --- /dev/null +++ b/clickhouse-1c/ops/aw-1c-weekly-digest.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Run AW-rus 1C weekly digest every Monday morning + +[Timer] +OnCalendar=Mon *-*-* 08:20:00 +Unit=aw-1c-weekly-digest.service +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/clickhouse-1c/ops/run_weekly_digest.sh b/clickhouse-1c/ops/run_weekly_digest.sh new file mode 100644 index 0000000..b2f02c0 --- /dev/null +++ b/clickhouse-1c/ops/run_weekly_digest.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${AW_1C_ROOT:-/opt/activitywatch/clickhouse-1c}" +ENV_FILE="${ROOT}/.env" +VENV="${ROOT}/.venv" + +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 + +exec "${VENV}/bin/python" "${ROOT}/ai/generate_weekly_digest.py"