feat(1c): add brief delta analysis and changes page
This commit is contained in:
@@ -98,6 +98,13 @@ def load_brief_history_record(name: str) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def extract_delta(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
delta = payload.get("context", {}).get("delta")
|
||||
if isinstance(delta, dict):
|
||||
return delta
|
||||
return {"available": False, "reason": "delta not present in artifact"}
|
||||
|
||||
|
||||
def grafana_company_dashboard_url() -> str:
|
||||
return os.getenv(
|
||||
"AW_1C_MANAGER_BRIEF_GRAFANA_URL",
|
||||
@@ -151,6 +158,7 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str:
|
||||
generated_at = payload.get("generated_at", "")
|
||||
history_url = "/api/1/analytics-1c/manager/brief/history"
|
||||
history_html_url = "/manager/briefs"
|
||||
delta_html_url = "/manager/changes"
|
||||
problematic_1d_url = "/manager/problematic?days=1"
|
||||
problematic_7d_url = "/manager/problematic?days=7"
|
||||
json_url = "/api/1/analytics-1c/manager/brief/latest"
|
||||
@@ -448,6 +456,7 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str:
|
||||
<a href="{html.escape(json_url)}">JSON</a>
|
||||
<a href="{html.escape(md_url)}">Markdown</a>
|
||||
<a href="{html.escape(history_html_url)}">История brief</a>
|
||||
<a href="{html.escape(delta_html_url)}">Что изменилось</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>
|
||||
@@ -605,6 +614,7 @@ def render_brief_history_html(items: list[dict[str, Any]]) -> str:
|
||||
f"<td>{html.escape(str(item.get('render_mode', '-')))}</td>"
|
||||
f"<td>{html.escape(str(item.get('model') or '-'))}</td>"
|
||||
f"<td>{html.escape(str(item.get('headline') or '-'))}</td>"
|
||||
f"<td><a class=\"inline-link\" href=\"/manager/briefs/{quote(str(item.get('path', '')))}/changes\">Изменения</a></td>"
|
||||
f"<td><a class=\"inline-link\" href=\"/api/1/analytics-1c/manager/brief/history/{quote(str(item.get('path', '')))}\">JSON</a></td>"
|
||||
"</tr>"
|
||||
)
|
||||
@@ -640,6 +650,7 @@ def render_brief_history_html(items: list[dict[str, Any]]) -> str:
|
||||
<h1>История executive brief</h1>
|
||||
<div class="hero-links">
|
||||
<a href="/manager/brief">Текущий brief</a>
|
||||
<a href="/manager/changes">Что изменилось</a>
|
||||
<a href="/manager/problematic?days=1">Проблемные 1д</a>
|
||||
<a href="/manager/problematic?days=7">Проблемные 7д</a>
|
||||
</div>
|
||||
@@ -652,11 +663,12 @@ def render_brief_history_html(items: list[dict[str, Any]]) -> str:
|
||||
<th>Режим</th>
|
||||
<th>Модель</th>
|
||||
<th>Headline</th>
|
||||
<th>Delta</th>
|
||||
<th>Raw</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{''.join(rows) or '<tr><td colspan="5">История пока пуста.</td></tr>'}
|
||||
{''.join(rows) or '<tr><td colspan="6">История пока пуста.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
@@ -757,6 +769,167 @@ def render_problematic_companies_html(items: list[dict[str, Any]], days: int) ->
|
||||
</html>"""
|
||||
|
||||
|
||||
def render_brief_delta_html(payload: dict[str, Any]) -> str:
|
||||
delta = extract_delta(payload)
|
||||
brief = payload.get("brief", {})
|
||||
if not delta.get("available"):
|
||||
return f"""<!doctype html>
|
||||
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>1C Brief Changes</title></head><body><main style="max-width:980px;margin:40px auto;font-family:IBM Plex Sans,Segoe UI,sans-serif">
|
||||
<h1>Что изменилось с прошлого запуска</h1><p>Delta пока недоступна: {html.escape(str(delta.get('reason', 'unknown')))}.</p>
|
||||
<p><a href="/manager/brief">Вернуться к brief</a></p></main></body></html>"""
|
||||
|
||||
summary = delta.get("summary", {})
|
||||
top_changes = delta.get("top_changes", [])
|
||||
new_critical = delta.get("new_critical", [])
|
||||
resolved_critical = delta.get("resolved_critical", [])
|
||||
entered_watchlist = delta.get("entered_watchlist", [])
|
||||
left_watchlist = delta.get("left_watchlist", [])
|
||||
|
||||
def delta_value(value: Any) -> str:
|
||||
try:
|
||||
numeric = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return html.escape(str(value))
|
||||
prefix = "+" if numeric > 0 else ""
|
||||
if numeric.is_integer():
|
||||
return prefix + str(int(numeric))
|
||||
return prefix + f"{numeric:.2f}".replace(".", ",")
|
||||
|
||||
stat_cards = [
|
||||
("Critical", delta_value(summary.get("critical_total_delta", 0))),
|
||||
("Busy", delta_value(summary.get("busy_total_delta", 0))),
|
||||
("Кейсы", delta_value(summary.get("open_cases_total_delta", 0))),
|
||||
("Detections", delta_value(summary.get("detections_total_delta", 0))),
|
||||
("Активность 30д", delta_value(summary.get("activity_30d_total_delta", 0))),
|
||||
("Прогноз 30д", delta_value(summary.get("activity_forecast_30d_total_delta", 0))),
|
||||
]
|
||||
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
|
||||
)
|
||||
|
||||
rows = []
|
||||
for item in top_changes:
|
||||
infobase = item.get("infobase")
|
||||
counterparty = item.get("company")
|
||||
rows.append(
|
||||
"<tr>"
|
||||
f"<td><a class=\"inline-link\" href=\"{company_detail_url(str(counterparty), str(infobase) if infobase else None)}\">{html.escape(str(counterparty or '-'))}</a></td>"
|
||||
f"<td>{html.escape(str(item.get('change_type') or '-'))}</td>"
|
||||
f"<td>{html.escape(str(item.get('severity_before') or '-'))} -> {html.escape(str(item.get('severity_after') or '-'))}</td>"
|
||||
f"<td>{delta_value(item.get('score_delta', 0))}</td>"
|
||||
f"<td>{delta_value(item.get('open_cases_delta', 0))}</td>"
|
||||
f"<td>{delta_value(item.get('active_locks_delta', 0))}</td>"
|
||||
f"<td>{delta_value(item.get('forecast_delta', 0))}</td>"
|
||||
f"<td>{html.escape(str(item.get('summary') or '-'))}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
|
||||
def as_list(items: list[str]) -> str:
|
||||
if not items:
|
||||
return "<li>Нет</li>"
|
||||
return "".join(f"<li>{html.escape(str(item))}</li>" for item in items)
|
||||
|
||||
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 Brief Changes</title>
|
||||
<style>
|
||||
:root {{ --bg:#f4f1ea; --paper:#fffdf8; --ink:#1c1a17; --muted:#6b655c; --line:#d8d0c4; --accent:#005f73; --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-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; }}
|
||||
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; }}
|
||||
.inline-link {{ color:var(--accent); text-decoration:none; font-weight:600; }}
|
||||
.inline-link:hover {{ text-decoration:underline; }}
|
||||
ul {{ margin:0; padding-left:20px; line-height:1.55; }}
|
||||
@media (max-width:1100px) {{ .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>Что изменилось с прошлого запуска</h1>
|
||||
<p>Сравнение between briefs: {html.escape(str(delta.get('previous_generated_at') or '-'))} -> {html.escape(str(delta.get('current_generated_at') or payload.get('generated_at') or '-'))}.</p>
|
||||
<div class="hero-links">
|
||||
<a href="/manager/brief">Текущий brief</a>
|
||||
<a href="/manager/briefs">История brief</a>
|
||||
<a href="/manager/problematic?days=1">Проблемные 1д</a>
|
||||
<a href="/manager/problematic?days=7">Проблемные 7д</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="panel span-12">
|
||||
<h2>Сводка изменений</h2>
|
||||
<div class="stats">{stat_html}</div>
|
||||
</article>
|
||||
|
||||
<article class="panel span-6">
|
||||
<h2>Новые critical</h2>
|
||||
<ul>{as_list(new_critical)}</ul>
|
||||
</article>
|
||||
|
||||
<article class="panel span-6">
|
||||
<h2>Вышли из critical</h2>
|
||||
<ul>{as_list(resolved_critical)}</ul>
|
||||
</article>
|
||||
|
||||
<article class="panel span-6">
|
||||
<h2>Зашли в watchlist</h2>
|
||||
<ul>{as_list(entered_watchlist)}</ul>
|
||||
</article>
|
||||
|
||||
<article class="panel span-6">
|
||||
<h2>Вышли из watchlist</h2>
|
||||
<ul>{as_list(left_watchlist)}</ul>
|
||||
</article>
|
||||
|
||||
<article class="panel span-12">
|
||||
<h2>Top changes today</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Компания</th>
|
||||
<th>Тип</th>
|
||||
<th>Severity</th>
|
||||
<th>Score Δ</th>
|
||||
<th>Cases Δ</th>
|
||||
<th>Locks Δ</th>
|
||||
<th>Forecast Δ</th>
|
||||
<th>Комментарий</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{''.join(rows) or '<tr><td colspan="8">Нет выраженных изменений.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
app = FastAPI(title="AW-rus 1C Company Intelligence API", version="1.0.0")
|
||||
|
||||
|
||||
@@ -967,6 +1140,17 @@ def manager_brief_history_record(name: str) -> dict[str, Any]:
|
||||
return load_brief_history_record(name)
|
||||
|
||||
|
||||
@app.get("/api/1/analytics-1c/manager/brief/delta/latest")
|
||||
def manager_brief_delta_latest() -> dict[str, Any]:
|
||||
payload = load_latest_manager_brief()
|
||||
delta = extract_delta(payload)
|
||||
return {
|
||||
"generated_at": payload.get("generated_at"),
|
||||
"headline": payload.get("brief", {}).get("headline"),
|
||||
"delta": delta,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/1/analytics-1c/companies/problematic")
|
||||
def problematic_companies_api(
|
||||
days: int = Query(default=7, ge=1, le=30),
|
||||
@@ -981,6 +1165,11 @@ def manager_brief_view() -> str:
|
||||
return render_manager_brief_html(load_latest_manager_brief())
|
||||
|
||||
|
||||
@app.get("/manager/changes", response_class=HTMLResponse)
|
||||
def manager_brief_delta_view() -> str:
|
||||
return render_brief_delta_html(load_latest_manager_brief())
|
||||
|
||||
|
||||
@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))
|
||||
@@ -991,6 +1180,11 @@ def manager_brief_history_detail_view(name: str) -> str:
|
||||
return render_manager_brief_html(load_brief_history_record(name))
|
||||
|
||||
|
||||
@app.get("/manager/briefs/{name}/changes", response_class=HTMLResponse)
|
||||
def manager_brief_history_delta_view(name: str) -> str:
|
||||
return render_brief_delta_html(load_brief_history_record(name))
|
||||
|
||||
|
||||
@app.get("/manager/problematic", response_class=HTMLResponse)
|
||||
def manager_problematic_companies_view(
|
||||
days: int = Query(default=7, ge=1, le=30),
|
||||
@@ -1189,6 +1383,7 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str |
|
||||
</div>
|
||||
<nav class="hero-links">
|
||||
<a href="/manager/brief">К портфелю</a>
|
||||
<a href="/manager/changes">Что изменилось</a>
|
||||
<a href="/manager/briefs">История brief</a>
|
||||
<a href="/manager/problematic?days=7">Проблемные компании</a>
|
||||
<a href="{html.escape(summary_url)}">JSON summary</a>
|
||||
|
||||
@@ -107,6 +107,26 @@ def q(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def severity_rank(value: str | None) -> int:
|
||||
return {
|
||||
"none": 0,
|
||||
"low": 1,
|
||||
"medium": 2,
|
||||
"high": 3,
|
||||
"critical": 4,
|
||||
}.get((value or "none").lower(), 0)
|
||||
|
||||
|
||||
def load_previous_artifact(state_dir: Path) -> dict[str, Any] | None:
|
||||
latest_path = state_dir / "latest.json"
|
||||
if not latest_path.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(latest_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def build_context(client, top_limit: int, freshness_hours: int) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
|
||||
@@ -283,6 +303,29 @@ def build_context(client, top_limit: int, freshness_hours: int) -> dict[str, Any
|
||||
)
|
||||
)
|
||||
|
||||
portfolio_snapshot = rows_to_dict(
|
||||
client.query(
|
||||
"""
|
||||
SELECT
|
||||
infobase,
|
||||
counterparty,
|
||||
normalized_counterparty,
|
||||
registry_match_mode,
|
||||
signal_severity,
|
||||
signal_score,
|
||||
current_status,
|
||||
active_locks,
|
||||
days_since_last_activity,
|
||||
round(amount_30d, 2) AS amount_30d,
|
||||
round(amount_forecast_30d, 2) AS amount_forecast_30d,
|
||||
open_cases_total,
|
||||
detections_total
|
||||
FROM analytics_1c.v_company_portfolio_overview
|
||||
ORDER BY counterparty, infobase
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"generated_at": now.isoformat(),
|
||||
"freshness_threshold_hours": freshness_hours,
|
||||
@@ -293,12 +336,200 @@ def build_context(client, top_limit: int, freshness_hours: int) -> dict[str, Any
|
||||
"watchlist": watchlist,
|
||||
"busy_bases": busy_bases,
|
||||
"recent_cases": recent_cases,
|
||||
"portfolio_snapshot": portfolio_snapshot,
|
||||
}
|
||||
|
||||
|
||||
def compute_delta_context(current: dict[str, Any], previous_artifact: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not previous_artifact:
|
||||
return {
|
||||
"available": False,
|
||||
"reason": "no previous brief artifact",
|
||||
"current_generated_at": current.get("generated_at"),
|
||||
}
|
||||
|
||||
previous = previous_artifact.get("context", {})
|
||||
current_summary = current.get("portfolio_summary", {})
|
||||
previous_summary = previous.get("portfolio_summary", {})
|
||||
current_watchlist = {(item.get("infobase"), item.get("counterparty")) for item in current.get("watchlist", [])}
|
||||
previous_watchlist = {(item.get("infobase"), item.get("counterparty")) for item in previous.get("watchlist", [])}
|
||||
current_snapshot = {
|
||||
(item.get("infobase"), item.get("counterparty")): item
|
||||
for item in current.get("portfolio_snapshot", [])
|
||||
}
|
||||
previous_snapshot = {
|
||||
(item.get("infobase"), item.get("counterparty")): item
|
||||
for item in previous.get("portfolio_snapshot", [])
|
||||
}
|
||||
|
||||
delta_summary = {
|
||||
"companies_total_delta": current_summary.get("companies_total", 0) - previous_summary.get("companies_total", 0),
|
||||
"critical_total_delta": current_summary.get("critical_total", 0) - previous_summary.get("critical_total", 0),
|
||||
"high_total_delta": current_summary.get("high_total", 0) - previous_summary.get("high_total", 0),
|
||||
"busy_total_delta": current_summary.get("busy_total", 0) - previous_summary.get("busy_total", 0),
|
||||
"open_cases_total_delta": current_summary.get("open_cases_total", 0) - previous_summary.get("open_cases_total", 0),
|
||||
"detections_total_delta": current_summary.get("detections_total", 0) - previous_summary.get("detections_total", 0),
|
||||
"activity_30d_total_delta": round(
|
||||
float(current_summary.get("activity_30d_total", 0) or 0)
|
||||
- float(previous_summary.get("activity_30d_total", 0) or 0),
|
||||
2,
|
||||
),
|
||||
"activity_forecast_30d_total_delta": round(
|
||||
float(current_summary.get("activity_forecast_30d_total", 0) or 0)
|
||||
- float(previous_summary.get("activity_forecast_30d_total", 0) or 0),
|
||||
2,
|
||||
),
|
||||
}
|
||||
|
||||
new_critical: list[str] = []
|
||||
resolved_critical: list[str] = []
|
||||
top_changes: list[dict[str, Any]] = []
|
||||
|
||||
for key, current_item in current_snapshot.items():
|
||||
previous_item = previous_snapshot.get(key)
|
||||
if not previous_item:
|
||||
continue
|
||||
|
||||
current_severity = str(current_item.get("signal_severity") or "none")
|
||||
previous_severity = str(previous_item.get("signal_severity") or "none")
|
||||
current_rank = severity_rank(current_severity)
|
||||
previous_rank = severity_rank(previous_severity)
|
||||
score_before = int(previous_item.get("signal_score") or 0)
|
||||
score_after = int(current_item.get("signal_score") or 0)
|
||||
score_delta = score_after - score_before
|
||||
cases_before = int(previous_item.get("open_cases_total") or 0)
|
||||
cases_after = int(current_item.get("open_cases_total") or 0)
|
||||
cases_delta = cases_after - cases_before
|
||||
detections_before = int(previous_item.get("detections_total") or 0)
|
||||
detections_after = int(current_item.get("detections_total") or 0)
|
||||
detections_delta = detections_after - detections_before
|
||||
locks_before = int(previous_item.get("active_locks") or 0)
|
||||
locks_after = int(current_item.get("active_locks") or 0)
|
||||
locks_delta = locks_after - locks_before
|
||||
forecast_before = float(previous_item.get("amount_forecast_30d") or 0)
|
||||
forecast_after = float(current_item.get("amount_forecast_30d") or 0)
|
||||
forecast_delta = round(forecast_after - forecast_before, 2)
|
||||
|
||||
if current_severity == "critical" and previous_severity != "critical":
|
||||
new_critical.append(str(current_item.get("counterparty") or "-"))
|
||||
if previous_severity == "critical" and current_severity != "critical":
|
||||
resolved_critical.append(str(current_item.get("counterparty") or "-"))
|
||||
|
||||
change_type = None
|
||||
summary = None
|
||||
significance = 0.0
|
||||
|
||||
if current_rank > previous_rank:
|
||||
change_type = "severity_up"
|
||||
summary = f"Severity {previous_severity} -> {current_severity}, score {score_before} -> {score_after}."
|
||||
significance = max(significance, (current_rank - previous_rank) * 50 + max(score_delta, 0))
|
||||
elif current_rank < previous_rank:
|
||||
change_type = "severity_down"
|
||||
summary = f"Severity {previous_severity} -> {current_severity}, напряжение по компании снизилось."
|
||||
significance = max(significance, (previous_rank - current_rank) * 40 + abs(score_delta))
|
||||
|
||||
if cases_delta > 0 and cases_delta * 6 > significance:
|
||||
change_type = "cases_up"
|
||||
summary = f"Открытых кейсов стало больше: {cases_before} -> {cases_after}."
|
||||
significance = cases_delta * 6 + max(score_delta, 0)
|
||||
|
||||
if locks_delta > 0 and locks_delta * 8 > significance:
|
||||
change_type = "locks_up"
|
||||
summary = f"Активные блокировки выросли: {locks_before} -> {locks_after}."
|
||||
significance = locks_delta * 8 + max(score_delta, 0)
|
||||
|
||||
if forecast_delta < 0:
|
||||
forecast_drop_pct = abs(forecast_delta) / max(abs(forecast_before), 1.0) * 100.0
|
||||
if forecast_drop_pct > significance:
|
||||
change_type = "forecast_drop"
|
||||
summary = f"Прогноз активности 30д снизился: {round(forecast_before, 2)} -> {round(forecast_after, 2)}."
|
||||
significance = forecast_drop_pct
|
||||
elif forecast_delta > 0:
|
||||
forecast_growth_pct = abs(forecast_delta) / max(abs(forecast_before), 1.0) * 100.0
|
||||
if forecast_growth_pct > significance and not change_type:
|
||||
change_type = "forecast_growth"
|
||||
summary = f"Прогноз активности 30д вырос: {round(forecast_before, 2)} -> {round(forecast_after, 2)}."
|
||||
significance = forecast_growth_pct
|
||||
|
||||
if detections_delta > 0 and detections_delta * 4 > significance:
|
||||
change_type = "detections_up"
|
||||
summary = f"Число detections выросло: {detections_before} -> {detections_after}."
|
||||
significance = detections_delta * 4
|
||||
|
||||
if not change_type:
|
||||
continue
|
||||
|
||||
top_changes.append(
|
||||
{
|
||||
"infobase": current_item.get("infobase"),
|
||||
"company": current_item.get("counterparty"),
|
||||
"normalized_counterparty": current_item.get("normalized_counterparty"),
|
||||
"registry_match_mode": current_item.get("registry_match_mode"),
|
||||
"change_type": change_type,
|
||||
"summary": summary,
|
||||
"severity_before": previous_severity,
|
||||
"severity_after": current_severity,
|
||||
"score_before": score_before,
|
||||
"score_after": score_after,
|
||||
"score_delta": score_delta,
|
||||
"open_cases_before": cases_before,
|
||||
"open_cases_after": cases_after,
|
||||
"open_cases_delta": cases_delta,
|
||||
"detections_before": detections_before,
|
||||
"detections_after": detections_after,
|
||||
"detections_delta": detections_delta,
|
||||
"active_locks_before": locks_before,
|
||||
"active_locks_after": locks_after,
|
||||
"active_locks_delta": locks_delta,
|
||||
"forecast_before": round(forecast_before, 2),
|
||||
"forecast_after": round(forecast_after, 2),
|
||||
"forecast_delta": forecast_delta,
|
||||
"significance": round(significance, 2),
|
||||
}
|
||||
)
|
||||
|
||||
entered_watchlist = sorted(
|
||||
key[1] for key in current_watchlist - previous_watchlist if key[1]
|
||||
)
|
||||
left_watchlist = sorted(
|
||||
key[1] for key in previous_watchlist - current_watchlist if key[1]
|
||||
)
|
||||
|
||||
top_changes.sort(
|
||||
key=lambda item: (
|
||||
float(item.get("significance") or 0),
|
||||
int(item.get("score_after") or 0),
|
||||
int(item.get("open_cases_after") or 0),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
delta_summary.update(
|
||||
{
|
||||
"new_critical_total": len(new_critical),
|
||||
"resolved_critical_total": len(resolved_critical),
|
||||
"entered_watchlist_total": len(entered_watchlist),
|
||||
"left_watchlist_total": len(left_watchlist),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"available": True,
|
||||
"previous_generated_at": previous.get("generated_at") or previous_artifact.get("generated_at"),
|
||||
"current_generated_at": current.get("generated_at"),
|
||||
"summary": delta_summary,
|
||||
"new_critical": new_critical[:10],
|
||||
"resolved_critical": resolved_critical[:10],
|
||||
"entered_watchlist": entered_watchlist[:10],
|
||||
"left_watchlist": left_watchlist[:10],
|
||||
"top_changes": top_changes[:15],
|
||||
}
|
||||
|
||||
|
||||
def render_deterministic_payload(context: dict[str, Any]) -> dict[str, Any]:
|
||||
summary = context["portfolio_summary"]
|
||||
freshness = context["freshness"]
|
||||
delta = context.get("delta", {})
|
||||
stale_sources = [item["source"] for item in freshness if item["stale"]]
|
||||
top_risks = context["top_risks"][:5]
|
||||
top_forecasts = context["top_forecasts"][:5]
|
||||
@@ -313,6 +544,19 @@ def render_deterministic_payload(context: dict[str, Any]) -> dict[str, Any]:
|
||||
f"Суммарная активность за 30 дней {summary['activity_30d_total']}, прогнозная активность на 30 дней {summary['activity_forecast_30d_total']}.",
|
||||
f"Открытых кейсов по компаниям {summary['open_cases_total']}, активных detections {summary['detections_total']}.",
|
||||
]
|
||||
if delta.get("available"):
|
||||
delta_summary = delta.get("summary", {})
|
||||
summary_lines.append(
|
||||
"С прошлого запуска: "
|
||||
f"critical {delta_summary.get('critical_total_delta', 0):+d}, "
|
||||
f"busy {delta_summary.get('busy_total_delta', 0):+d}, "
|
||||
f"кейсы {delta_summary.get('open_cases_total_delta', 0):+d}, "
|
||||
f"detections {delta_summary.get('detections_total_delta', 0):+d}."
|
||||
)
|
||||
if delta.get("top_changes"):
|
||||
leaders = ", ".join(item["company"] for item in delta["top_changes"][:3] if item.get("company"))
|
||||
if leaders:
|
||||
summary_lines.append(f"Главные изменения с прошлого запуска: {leaders}.")
|
||||
if stale_sources:
|
||||
summary_lines.append(f"Есть просрочка по источникам: {', '.join(stale_sources)}.")
|
||||
else:
|
||||
@@ -348,6 +592,8 @@ def render_deterministic_payload(context: dict[str, Any]) -> dict[str, Any]:
|
||||
"Проверить watchlist по inactivity/amount_drop/docs_stopped и подтвердить, это бизнес-пауза или operational сбой.",
|
||||
"Отдельно пройти по manual-match компаниям перед управленческими выводами из реестра.",
|
||||
]
|
||||
if delta.get("available") and delta.get("summary", {}).get("new_critical_total", 0) > 0:
|
||||
actions.insert(0, f"Сначала разобрать новые critical-компании: {', '.join(delta.get('new_critical', [])[:3])}.")
|
||||
if watchlist:
|
||||
actions[1] = (
|
||||
f"Проверить watchlist: {', '.join(item['counterparty'] for item in watchlist[:3])}."
|
||||
@@ -471,8 +717,10 @@ def main() -> int:
|
||||
state_dir = Path(args.state_dir)
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
previous_artifact = load_previous_artifact(state_dir)
|
||||
client = ch_client(args)
|
||||
context = build_context(client, top_limit=args.top_limit, freshness_hours=args.freshness_hours)
|
||||
context["delta"] = compute_delta_context(context, previous_artifact)
|
||||
prompt = build_prompt(context)
|
||||
|
||||
codex_rc = None
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
- Если в данных `amount` означает activity score, называй это "активность", а не "выручка" или "деньги".
|
||||
- Если почти все компании в high/critical, явно скажи, что severity сейчас operational-driven и не равна финансовому кризису.
|
||||
- Для `registry_match_mode=manual` не делай сильных выводов о юридическом соответствии реестру.
|
||||
- Если в контексте есть delta с прошлого запуска, явно выдели, что ухудшилось и что улучшилось.
|
||||
- Приоритет: риски, прогноз, что проверить руководителю в первую очередь.
|
||||
|
||||
Верни JSON строго по schema.
|
||||
|
||||
@@ -105,6 +105,49 @@ class CompanyIntelligenceApiTests(unittest.TestCase):
|
||||
else:
|
||||
os.environ["AW_1C_MANAGER_BRIEF_STATE_DIR"] = old
|
||||
|
||||
def test_render_brief_delta_html(self) -> None:
|
||||
payload = {
|
||||
"generated_at": "2026-05-22T12:00:00+00:00",
|
||||
"brief": {"headline": "Тест"},
|
||||
"context": {
|
||||
"delta": {
|
||||
"available": True,
|
||||
"previous_generated_at": "2026-05-22T09:00:00+00:00",
|
||||
"current_generated_at": "2026-05-22T12:00:00+00:00",
|
||||
"summary": {
|
||||
"critical_total_delta": 2,
|
||||
"busy_total_delta": 1,
|
||||
"open_cases_total_delta": 5,
|
||||
"detections_total_delta": 4,
|
||||
"activity_30d_total_delta": 120.5,
|
||||
"activity_forecast_30d_total_delta": -50.25,
|
||||
},
|
||||
"new_critical": ["ФЕЛИЦТ ГРУПП 2026"],
|
||||
"resolved_critical": [],
|
||||
"entered_watchlist": ["АВКО 2026"],
|
||||
"left_watchlist": [],
|
||||
"top_changes": [
|
||||
{
|
||||
"infobase": "ФЕЛИЦТ ГРУПП 2026",
|
||||
"company": "ФЕЛИЦТ ГРУПП 2026",
|
||||
"change_type": "severity_up",
|
||||
"severity_before": "high",
|
||||
"severity_after": "critical",
|
||||
"score_delta": 25,
|
||||
"open_cases_delta": 3,
|
||||
"active_locks_delta": 2,
|
||||
"forecast_delta": -15.0,
|
||||
"summary": "Severity high -> critical.",
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
html_page = api.render_brief_delta_html(payload)
|
||||
self.assertIn("Что изменилось с прошлого запуска", html_page)
|
||||
self.assertIn("Top changes today", html_page)
|
||||
self.assertIn("ФЕЛИЦТ ГРУПП 2026", html_page)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -74,6 +74,66 @@ class ManagerBriefTests(unittest.TestCase):
|
||||
self.assertIn("## Компании риска", md)
|
||||
self.assertIn("ФЕЛИЦТ ГРУПП 2026", md)
|
||||
|
||||
def test_compute_delta_context(self) -> None:
|
||||
previous_artifact = {
|
||||
"generated_at": "2026-05-22T09:00:00+00:00",
|
||||
"context": {
|
||||
"generated_at": "2026-05-22T09:00:00+00:00",
|
||||
"portfolio_summary": {
|
||||
"companies_total": 40,
|
||||
"critical_total": 8,
|
||||
"high_total": 18,
|
||||
"busy_total": 6,
|
||||
"open_cases_total": 10,
|
||||
"detections_total": 18,
|
||||
"activity_30d_total": 12000.0,
|
||||
"activity_forecast_30d_total": 50000.0,
|
||||
},
|
||||
"watchlist": [{"infobase": "ИБ1", "counterparty": "СТАРАЯ КОМПАНИЯ"}],
|
||||
"portfolio_snapshot": [
|
||||
{
|
||||
"infobase": "ИБ1",
|
||||
"counterparty": "ФЕЛИЦТ ГРУПП 2026",
|
||||
"normalized_counterparty": "ФЕЛИЦТ ГРУПП",
|
||||
"registry_match_mode": "manual",
|
||||
"signal_severity": "high",
|
||||
"signal_score": 70,
|
||||
"current_status": "idle",
|
||||
"active_locks": 1,
|
||||
"days_since_last_activity": 0,
|
||||
"amount_30d": 100.0,
|
||||
"amount_forecast_30d": 200.0,
|
||||
"open_cases_total": 1,
|
||||
"detections_total": 2,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
current = dict(self.context)
|
||||
current["generated_at"] = "2026-05-22T12:00:00+00:00"
|
||||
current["portfolio_snapshot"] = [
|
||||
{
|
||||
"infobase": "ИБ1",
|
||||
"counterparty": "ФЕЛИЦТ ГРУПП 2026",
|
||||
"normalized_counterparty": "ФЕЛИЦТ ГРУПП",
|
||||
"registry_match_mode": "manual",
|
||||
"signal_severity": "critical",
|
||||
"signal_score": 95,
|
||||
"current_status": "busy",
|
||||
"active_locks": 3,
|
||||
"days_since_last_activity": 0,
|
||||
"amount_30d": 100.0,
|
||||
"amount_forecast_30d": 150.0,
|
||||
"open_cases_total": 4,
|
||||
"detections_total": 5,
|
||||
}
|
||||
]
|
||||
delta = gmb.compute_delta_context(current, previous_artifact)
|
||||
self.assertTrue(delta["available"])
|
||||
self.assertEqual(delta["summary"]["critical_total_delta"], 2)
|
||||
self.assertIn("ФЕЛИЦТ ГРУПП 2026", delta["new_critical"])
|
||||
self.assertEqual(delta["top_changes"][0]["change_type"], "severity_up")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user