feat(1c): add weekly digest and hard priority pages
This commit is contained in:
@@ -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:
|
||||
<a href="{html.escape(history_html_url)}">История brief</a>
|
||||
<a href="{html.escape(delta_html_url)}">Что изменилось</a>
|
||||
<a href="{html.escape(weekly_html_url)}">Неделя</a>
|
||||
<a href="{html.escape(weekly_digest_url)}">Weekly digest</a>
|
||||
<a href="{html.escape(problematic_1d_url)}">Проблемные 1д</a>
|
||||
<a href="{html.escape(problematic_7d_url)}">Проблемные 7д</a>
|
||||
<a href="{html.escape(history_url)}">History API</a>
|
||||
@@ -1004,6 +1151,7 @@ def render_brief_delta_html(payload: dict[str, Any]) -> str:
|
||||
<a href="/manager/brief">Текущий brief</a>
|
||||
<a href="/manager/briefs">История brief</a>
|
||||
<a href="/manager/trends/weekly">Неделя</a>
|
||||
<a href="/manager/digest/weekly">Weekly digest</a>
|
||||
<a href="/manager/problematic?days=1">Проблемные 1д</a>
|
||||
<a href="/manager/problematic?days=7">Проблемные 7д</a>
|
||||
</div>
|
||||
@@ -1160,6 +1308,7 @@ def render_weekly_trend_html(report: dict[str, Any]) -> str:
|
||||
<a href="/manager/changes">Что изменилось</a>
|
||||
<a href="/manager/briefs">История brief</a>
|
||||
<a href="/manager/problematic?days=7">Проблемные 7д</a>
|
||||
<a href="/manager/digest/weekly">Weekly digest</a>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
@@ -1213,6 +1362,156 @@ def render_weekly_trend_html(report: dict[str, Any]) -> str:
|
||||
</html>"""
|
||||
|
||||
|
||||
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"<div class=\"stat\"><div class=\"stat-label\">{html.escape(label)}</div><div class=\"stat-value\">{html.escape(value)}</div></div>"
|
||||
for label, value in stat_cards
|
||||
)
|
||||
|
||||
priority_cards = []
|
||||
for item in top_priorities:
|
||||
company = str(item.get("company") or "-")
|
||||
priority_cards.append(
|
||||
"<article class=\"stack-card\">"
|
||||
f"<div class=\"stack-card-head\"><h3>{html.escape(company)}</h3>{severity_badge(str(item.get('priority') or 'low'))}</div>"
|
||||
f"<p class=\"stack-card-body\">{html.escape(str(item.get('reason') or '-'))}</p>"
|
||||
f"<p class=\"stack-card-action\"><strong>Действие:</strong> {html.escape(str(item.get('recommended_action') or '-'))}</p>"
|
||||
f"<p class=\"stack-card-action\"><a class=\"inline-link\" href=\"{company_detail_url(company)}\">Карточка компании</a></p>"
|
||||
"</article>"
|
||||
)
|
||||
|
||||
improvement_rows = []
|
||||
for item in improvements:
|
||||
improvement_rows.append(
|
||||
"<tr>"
|
||||
f"<td><a class=\"inline-link\" href=\"{company_detail_url(str(item.get('company') or '-'), str(item.get('infobase') or '') or None)}\">{html.escape(str(item.get('company') or '-'))}</a></td>"
|
||||
f"<td>{html.escape(str(item.get('signal') or '-'))}</td>"
|
||||
f"<td>{html.escape(str(item.get('meaning') or '-'))}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
|
||||
summary_items = "\n".join(f"<li>{html.escape(str(item))}</li>" for item in summary)
|
||||
action_items = "\n".join(f"<li>{html.escape(str(item))}</li>" for item in actions)
|
||||
caveat_items = "\n".join(f"<li>{html.escape(str(item))}</li>" for item in caveats)
|
||||
|
||||
return f"""<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="refresh" content="300">
|
||||
<title>1C Weekly Digest</title>
|
||||
<style>
|
||||
:root {{
|
||||
--bg:#f4f1ea; --paper:#fffdf8; --ink:#1c1a17; --muted:#6b655c; --line:#d8d0c4; --accent:#005f73;
|
||||
--critical:#9b2226; --high:#bb3e03; --medium:#ca6702; --low:#4d7c0f; --none:#687076; --shadow:0 14px 40px rgba(28,26,23,.08);
|
||||
}}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; font-family:"IBM Plex Sans","Segoe UI",system-ui,sans-serif; color:var(--ink); background:linear-gradient(180deg,#faf7f2 0%,var(--bg) 100%); }}
|
||||
.shell {{ max-width:1480px; margin:0 auto; padding:28px 22px 60px; }}
|
||||
.hero {{ background:linear-gradient(135deg,rgba(0,95,115,.94),rgba(10,77,104,.92)); color:#f8fbfc; border-radius:24px; padding:28px 30px; box-shadow:var(--shadow); }}
|
||||
.hero h1 {{ margin:0 0 10px; font-size:clamp(28px,4vw,42px); }}
|
||||
.hero p {{ margin:0 0 14px; color:rgba(248,251,252,.88); line-height:1.5; }}
|
||||
.hero-links {{ display:flex; gap:10px; flex-wrap:wrap; }}
|
||||
.hero-links a {{ text-decoration:none; color:#f8fbfc; border:1px solid rgba(248,251,252,.28); padding:9px 12px; border-radius:999px; font-size:14px; }}
|
||||
.grid {{ display:grid; grid-template-columns:repeat(12,minmax(0,1fr)); gap:18px; margin-top:22px; }}
|
||||
.panel {{ background:var(--paper); border:1px solid var(--line); border-radius:22px; padding:22px; box-shadow:var(--shadow); }}
|
||||
.span-12 {{ grid-column:span 12; }} .span-8 {{ grid-column:span 8; }} .span-6 {{ grid-column:span 6; }} .span-4 {{ grid-column:span 4; }}
|
||||
.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; }}
|
||||
.stat-value {{ font-size:24px; font-weight:700; letter-spacing:-.03em; }}
|
||||
.stack {{ display:grid; gap:14px; }}
|
||||
.stack-card {{ border:1px solid var(--line); border-radius:18px; padding:16px 18px; background:#fffdfa; }}
|
||||
.stack-card-head {{ display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:10px; }}
|
||||
.stack-card-body,.stack-card-action {{ margin:0; line-height:1.5; }}
|
||||
.stack-card-action {{ margin-top:10px; color:var(--muted); }}
|
||||
.badge {{ display:inline-flex; align-items:center; justify-content:center; padding:6px 10px; border-radius:999px; font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:#fff; }}
|
||||
.badge-critical {{ background:var(--critical); }} .badge-high {{ background:var(--high); }} .badge-medium {{ background:var(--medium); }} .badge-low {{ background:var(--low); }} .badge-none {{ background:var(--none); }}
|
||||
.inline-link {{ color:var(--accent); text-decoration:none; font-weight:600; }} .inline-link:hover {{ text-decoration:underline; }}
|
||||
table {{ width:100%; border-collapse:collapse; font-size:14px; }}
|
||||
th,td {{ text-align:left; padding:10px 8px; border-bottom:1px solid var(--line); vertical-align:top; }}
|
||||
th {{ color:var(--muted); font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:.06em; }}
|
||||
ul {{ margin:0; padding-left:20px; line-height:1.55; }}
|
||||
@media (max-width:1100px) {{ .span-8,.span-6,.span-4 {{ grid-column:span 12; }} .stats {{ grid-template-columns:repeat(2,minmax(0,1fr)); }} }}
|
||||
@media (max-width:640px) {{ .shell {{ padding:16px 14px 40px; }} .hero {{ padding:22px 18px; }} .stats {{ grid-template-columns:1fr; }} }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<h1>{html.escape(str(digest.get('headline') or 'Weekly digest'))}</h1>
|
||||
<p>Период: {html.escape(str(context.get('period_start') or '-'))} -> {html.escape(str(context.get('period_end') or '-'))}. Сформировано: {html.escape(str(generated_at or '-'))}.</p>
|
||||
<div class="hero-links">
|
||||
<a href="/manager/brief">Текущий brief</a>
|
||||
<a href="/manager/changes">Что изменилось</a>
|
||||
<a href="/manager/trends/weekly">Неделя</a>
|
||||
<a href="/manager/problematic?days=7">Проблемные 7д</a>
|
||||
<a href="/api/1/analytics-1c/manager/digest/weekly/latest">JSON</a>
|
||||
<a href="/api/1/analytics-1c/manager/digest/weekly/latest.md">Markdown</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="panel span-8">
|
||||
<h2>Кратко для руководителя</h2>
|
||||
<ul>{summary_items}</ul>
|
||||
</article>
|
||||
<article class="panel span-4">
|
||||
<h2>Срез недели</h2>
|
||||
<div class="stats">{stat_html}</div>
|
||||
</article>
|
||||
|
||||
<article class="panel span-8">
|
||||
<h2>Компании первой очереди</h2>
|
||||
<div class="stack">{''.join(priority_cards) or '<p>Нет weekly priorities.</p>'}</div>
|
||||
</article>
|
||||
|
||||
<article class="panel span-4">
|
||||
<h2>Что делать</h2>
|
||||
<ul>{action_items}</ul>
|
||||
</article>
|
||||
|
||||
<article class="panel span-12">
|
||||
<h2>Что улучшилось за неделю</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Компания</th><th>Сигнал</th><th>Что это значит</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{''.join(improvement_rows) or '<tr><td colspan="3">Явных улучшений за неделю не зафиксировано.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<article class="panel span-12">
|
||||
<h2>Ограничения интерпретации</h2>
|
||||
<ul>{caveat_items}</ul>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
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"<li>{html.escape(str(item))}</li>"
|
||||
for item in priority_context.get("evidence", [])
|
||||
)
|
||||
action_items = "".join(
|
||||
f"<li>{html.escape(str(item))}</li>"
|
||||
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 |
|
||||
<a href="/manager/brief">К портфелю</a>
|
||||
<a href="/manager/changes">Что изменилось</a>
|
||||
<a href="/manager/trends/weekly">Неделя</a>
|
||||
<a href="/manager/digest/weekly">Weekly digest</a>
|
||||
<a href="/manager/briefs">История brief</a>
|
||||
<a href="/manager/problematic?days=7">Проблемные компании</a>
|
||||
<a href="{html.escape(summary_url)}">JSON summary</a>
|
||||
@@ -1705,6 +2039,22 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str |
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel span-8">
|
||||
<h2>Почему приоритет высокий именно у этой компании</h2>
|
||||
<div class="summary-box summary-box-priority">
|
||||
<p><strong>{html.escape(str(priority_context.get("verdict") or "-"))}</strong></p>
|
||||
<p>Текущий приоритет: {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 "-"))}</p>
|
||||
<ul class="meta-list">{evidence_items or '<li>Жёстких драйверов приоритета не зафиксировано.</li>'}</ul>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel span-4">
|
||||
<h2>Что проверить первым действием</h2>
|
||||
<ul class="meta-list">{action_items}</ul>
|
||||
</article>
|
||||
|
||||
<article class="panel span-6">
|
||||
<h2>Карточка компании</h2>
|
||||
<ul class="meta-list">
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
{{CONTEXT_JSON}}
|
||||
</context_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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user