feat(1c): add company intelligence forecasting layer

This commit is contained in:
igor04091968
2026-05-22 10:11:53 +03:00
parent da58d682c1
commit f7114bde03
17 changed files with 1539 additions and 3 deletions
+50
View File
@@ -15,6 +15,9 @@ AI Investigator не должен ходить напрямую в файлов
- что произошло по case `X`;
- собрать summary по entity timeline;
- предложить next steps без write-действий.
- какие компании выпали из активности;
- где ожидается спад или рост объёма по компаниям;
- какие компании требуют проверки из-за резкого падения документооборота.
## Рекомендуемые API endpoints
@@ -61,6 +64,52 @@ AI Investigator не должен ходить напрямую в файлов
- related detections
- related timeline rows
### `GET /api/1/analytics-1c/companies/overview`
Фильтры:
- `infobase`
- `min_signal_score`
- `limit`
Возвращает:
- компании с активностью за 30 дней
- последние сигналы риска
- прогноз `amount/docs` на `7/30` дней
### `GET /api/1/analytics-1c/companies/{counterparty}/summary`
Возвращает:
- текущую карточку компании
- AI-ready short summary
- последние документы
- forecasts
- signals
### `GET /api/1/analytics-1c/companies/{counterparty}/forecast`
Возвращает:
- `metric`
- `horizon_days`
- `baseline_daily`
- `trend_slope`
- `predicted_daily`
- `predicted_total`
- `confidence`
### `GET /api/1/analytics-1c/companies/{counterparty}/timeline`
Возвращает:
- последние документы по компании
- базу
- автора
- тип операции
- статус
## Guardrails
- read-only SQL;
@@ -68,6 +117,7 @@ AI Investigator не должен ходить напрямую в файлов
- no direct write-back into 1С;
- no direct execution of arbitrary SQL from prompt;
- all investigator requests are logged.
- если live-данные не содержат `counterparty`, company endpoints должны честно возвращать пустой результат, а не симулировать прогноз.
## Output style
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
import clickhouse_connect
import uvicorn
from fastapi import FastAPI, HTTPException, Query
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Read-only company intelligence API for analytics_1c")
p.add_argument("--host", default=os.getenv("AW_1C_COMPANY_API_HOST", "127.0.0.1"))
p.add_argument("--port", type=int, default=int(os.getenv("AW_1C_COMPANY_API_PORT", "8710")))
return p.parse_args()
def q(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def to_plain(value: Any) -> Any:
if isinstance(value, Decimal):
return float(value)
if isinstance(value, datetime):
return value.isoformat()
return value
def rows_to_dict(result) -> list[dict[str, Any]]:
return [
{name: to_plain(value) for name, value in zip(result.column_names, row)}
for row in result.result_rows
]
def ch_client():
return clickhouse_connect.get_client(
host=os.getenv("CLICKHOUSE_HOST", "localhost"),
port=int(os.getenv("CLICKHOUSE_PORT", "8123")),
username=os.getenv("CLICKHOUSE_USER", "default"),
password=os.getenv("CLICKHOUSE_PASSWORD", ""),
database=os.getenv("CLICKHOUSE_DB", "analytics_1c"),
)
app = FastAPI(title="AW-rus 1C Company Intelligence API", version="1.0.0")
@app.get("/health")
def health() -> dict[str, Any]:
client = ch_client()
summary = rows_to_dict(
client.query(
"""
SELECT
countIf(counterparty != '') AS documents_with_counterparty,
(SELECT count() FROM analytics_1c.company_forecasts) AS forecasts_total,
(SELECT count() FROM analytics_1c.company_health_signals) AS health_signals_total
FROM analytics_1c.documents
"""
)
)[0]
return {"status": "ok", "generated_at": datetime.now(UTC).isoformat(), **summary}
@app.get("/api/1/analytics-1c/companies/overview")
def companies_overview(
infobase: str | None = None,
min_signal_score: int = Query(default=0, ge=0, le=100),
limit: int = Query(default=50, ge=1, le=500),
) -> dict[str, Any]:
client = ch_client()
where = [f"signal_score >= {int(min_signal_score)}"]
if infobase:
where.append(f"infobase = {q(infobase)}")
sql = f"""
SELECT
infobase,
organization,
counterparty,
last_seen_at,
days_since_last_activity,
docs_7d,
amount_7d,
docs_30d,
amount_30d,
amount_forecast_30d,
docs_forecast_30d,
signal_severity,
signal_score,
top_signal
FROM analytics_1c.v_company_portfolio_overview
WHERE {' AND '.join(where)}
ORDER BY signal_score DESC, amount_30d DESC, counterparty
LIMIT {int(limit)}
"""
rows = rows_to_dict(client.query(sql))
return {"items": rows, "count": len(rows)}
@app.get("/api/1/analytics-1c/companies/{counterparty}/summary")
def company_summary(counterparty: str, infobase: str | None = None) -> dict[str, Any]:
client = ch_client()
filters = [f"counterparty = {q(counterparty)}"]
if infobase:
filters.append(f"infobase = {q(infobase)}")
sql = f"""
SELECT *
FROM analytics_1c.v_company_portfolio_overview
WHERE {' AND '.join(filters)}
ORDER BY amount_30d DESC
LIMIT 1
"""
rows = rows_to_dict(client.query(sql))
if not rows:
raise HTTPException(status_code=404, detail="counterparty not found in analytics_1c.v_company_portfolio_overview")
card = rows[0]
forecast_sql = f"""
SELECT metric, horizon_days, baseline_daily, trend_slope, predicted_daily, predicted_total, confidence, note
FROM analytics_1c.v_company_forecasts_current
WHERE counterparty = {q(counterparty)}
{"AND infobase = " + q(infobase) if infobase else ""}
ORDER BY metric, horizon_days
"""
signals_sql = f"""
SELECT generated_at, severity, score, signal_type, summary
FROM analytics_1c.v_company_health_current
WHERE counterparty = {q(counterparty)}
{"AND infobase = " + q(infobase) if infobase else ""}
ORDER BY score DESC, generated_at DESC
"""
timeline_sql = f"""
SELECT ts, infobase, doc_type, operation_type, amount, status, author
FROM analytics_1c.documents
WHERE counterparty = {q(counterparty)}
{"AND infobase = " + q(infobase) if infobase else ""}
ORDER BY ts DESC
LIMIT 20
"""
forecasts = rows_to_dict(client.query(forecast_sql))
signals = rows_to_dict(client.query(signals_sql))
timeline = rows_to_dict(client.query(timeline_sql))
essence = (
f"Компания {counterparty}: за 30 дней документов {card['docs_30d']}, объём {card['amount_30d']}, "
f"прогноз на 30 дней {card['amount_forecast_30d']}, риск {card['signal_severity']}."
)
return {
"essence": essence,
"card": card,
"forecasts": forecasts,
"signals": signals,
"recent_documents": timeline,
}
@app.get("/api/1/analytics-1c/companies/{counterparty}/forecast")
def company_forecast(
counterparty: str,
infobase: str | None = None,
horizon_days: int | None = Query(default=None, ge=1, le=365),
) -> dict[str, Any]:
client = ch_client()
filters = [f"counterparty = {q(counterparty)}"]
if infobase:
filters.append(f"infobase = {q(infobase)}")
if horizon_days is not None:
filters.append(f"horizon_days = {int(horizon_days)}")
sql = f"""
SELECT generated_at, infobase, counterparty, metric, horizon_days, baseline_daily, trend_slope, predicted_daily, predicted_total, confidence, note
FROM analytics_1c.v_company_forecasts_current
WHERE {' AND '.join(filters)}
ORDER BY metric, horizon_days
"""
rows = rows_to_dict(client.query(sql))
return {"items": rows, "count": len(rows)}
@app.get("/api/1/analytics-1c/companies/{counterparty}/timeline")
def company_timeline(
counterparty: str,
infobase: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
) -> dict[str, Any]:
client = ch_client()
filters = [f"counterparty = {q(counterparty)}"]
if infobase:
filters.append(f"infobase = {q(infobase)}")
sql = f"""
SELECT ts, infobase, organization, doc_type, doc_number, author, operation_type, amount, status, posted
FROM analytics_1c.documents
WHERE {' AND '.join(filters)}
ORDER BY ts DESC
LIMIT {int(limit)}
"""
rows = rows_to_dict(client.query(sql))
return {"items": rows, "count": len(rows)}
if __name__ == "__main__":
args = parse_args()
uvicorn.run(app, host=args.host, port=args.port)
@@ -0,0 +1,301 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import math
import os
from collections import defaultdict
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
from statistics import fmean, pstdev
from typing import Any
import clickhouse_connect
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Refresh company forecasts and health signals for analytics_1c")
p.add_argument("--host", default=os.getenv("CLICKHOUSE_HOST", "localhost"))
p.add_argument("--port", type=int, default=int(os.getenv("CLICKHOUSE_PORT", "8123")))
p.add_argument("--user", default=os.getenv("CLICKHOUSE_USER", "default"))
p.add_argument("--password", default=os.getenv("CLICKHOUSE_PASSWORD", ""))
p.add_argument("--database", default=os.getenv("CLICKHOUSE_DB", "analytics_1c"))
p.add_argument("--lookback-days", type=int, default=int(os.getenv("AW_1C_COMPANY_LOOKBACK_DAYS", "30")))
p.add_argument("--min-days", type=int, default=int(os.getenv("AW_1C_COMPANY_MIN_DAYS", "3")))
p.add_argument("--horizons", default=os.getenv("AW_1C_COMPANY_HORIZONS", "7,30"))
return p.parse_args()
@dataclass
class DailyPoint:
d: date
docs_total: float
amount_total: float
def ch_client(args: argparse.Namespace):
return clickhouse_connect.get_client(
host=args.host,
port=args.port,
username=args.user,
password=args.password,
database=args.database,
)
def query_rows(client, sql: str) -> list[dict[str, Any]]:
result = client.query(sql)
return [dict(zip(result.column_names, row)) for row in result.result_rows]
def fill_daily_series(points: list[DailyPoint]) -> list[DailyPoint]:
if not points:
return []
by_day = {p.d: p for p in points}
current = points[0].d
end = points[-1].d
filled: list[DailyPoint] = []
while current <= end:
filled.append(by_day.get(current, DailyPoint(current, 0.0, 0.0)))
current += timedelta(days=1)
return filled
def linear_slope(values: list[float]) -> float:
n = len(values)
if n < 2:
return 0.0
x_mean = (n - 1) / 2
y_mean = fmean(values)
num = sum((i - x_mean) * (v - y_mean) for i, v in enumerate(values))
den = sum((i - x_mean) ** 2 for i in range(n))
if den == 0:
return 0.0
return num / den
def build_forecast(values: list[float], horizon: int, min_days: int, lookback_days: int) -> tuple[float, float, float, float, int, str]:
if len(values) < min_days:
raise ValueError("not enough data")
window = values[-min(len(values), lookback_days):]
baseline = fmean(window)
slope = linear_slope(window)
projected = [max(0.0, baseline + slope * step) for step in range(1, horizon + 1)]
predicted_total = sum(projected)
predicted_daily = projected[-1] if projected else baseline
if len(window) > 1 and baseline > 0:
volatility = pstdev(window) / baseline
elif len(window) > 1:
volatility = pstdev(window)
else:
volatility = 0.0
coverage = min(1.0, len(window) / max(lookback_days, 1))
stability = max(0.15, 1.0 - min(volatility, 1.0))
confidence = max(0.1, min(0.95, coverage * stability))
note_parts: list[str] = []
if len(values) < lookback_days:
note_parts.append("sparse_history")
if abs(slope) < 0.01:
note_parts.append("flat_trend")
note = ",".join(note_parts) if note_parts else "ok"
return baseline, slope, predicted_daily, predicted_total, len(window), note
def severity_score_to_label(score: int) -> str:
if score >= 80:
return "critical"
if score >= 60:
return "high"
if score >= 35:
return "medium"
return "low"
def main() -> int:
args = parse_args()
client = ch_client(args)
horizons = [int(x.strip()) for x in args.horizons.split(",") if x.strip()]
generated_at = datetime.now(UTC).replace(tzinfo=None, microsecond=0)
daily_rows = query_rows(
client,
"""
SELECT infobase, organization, counterparty, d, docs_total, amount_total
FROM analytics_1c.v_counterparty_daily
ORDER BY infobase, counterparty, d
""",
)
if not daily_rows:
print("no counterparty rows in analytics_1c.v_counterparty_daily; nothing to refresh")
return 0
grouped: dict[tuple[str, str, str], list[DailyPoint]] = defaultdict(list)
for row in daily_rows:
key = (row["infobase"], row["organization"], row["counterparty"])
grouped[key].append(
DailyPoint(
d=row["d"],
docs_total=float(row["docs_total"] or 0),
amount_total=float(row["amount_total"] or 0),
)
)
cases_map = {
(row["infobase"], row["counterparty"]): int(row["open_cases_total"] or 0)
for row in query_rows(
client,
"""
SELECT infobase, entity_id AS counterparty, countIf(status != 'closed') AS open_cases_total
FROM analytics_1c.cases
WHERE entity_type = 'counterparty'
GROUP BY infobase, counterparty
""",
)
}
detections_map = {
(row["infobase"], row["counterparty"]): int(row["detections_total"] or 0)
for row in query_rows(
client,
"""
SELECT infobase, entity_id AS counterparty, count() AS detections_total
FROM analytics_1c.detections
WHERE entity_type = 'counterparty' AND status != 'closed'
GROUP BY infobase, counterparty
""",
)
}
forecast_rows: list[list[Any]] = []
signal_rows: list[list[Any]] = []
for (infobase, _organization, counterparty), points in grouped.items():
points.sort(key=lambda p: p.d)
filled = fill_daily_series(points)
docs_series = [p.docs_total for p in filled]
amount_series = [p.amount_total for p in filled]
if len(filled) < args.min_days:
continue
latest_day = filled[-1].d
last_7 = filled[-7:]
prev_7 = filled[-14:-7]
docs_7d = int(sum(p.docs_total for p in last_7))
docs_prev_7d = int(sum(p.docs_total for p in prev_7))
amount_7d = float(sum(p.amount_total for p in last_7))
amount_prev_7d = float(sum(p.amount_total for p in prev_7))
days_since_last_activity = (date.today() - latest_day).days
open_cases_total = cases_map.get((infobase, counterparty), 0)
detections_total = detections_map.get((infobase, counterparty), 0)
for metric, values in (("docs_total", docs_series), ("amount_total", amount_series)):
for horizon in horizons:
baseline, slope, predicted_daily, predicted_total, source_days, note = build_forecast(
values=values,
horizon=horizon,
min_days=args.min_days,
lookback_days=args.lookback_days,
)
forecast_rows.append(
[
generated_at,
latest_day,
infobase,
counterparty,
int(horizon),
metric,
float(baseline),
float(slope),
float(predicted_daily),
float(predicted_total),
round(float(max(0.1, min(0.95, 1.0 - abs(slope) / (abs(baseline) + 1.0)))), 4),
"linear_baseline",
int(source_days),
note,
]
)
signals: list[tuple[str, int, str, str]] = []
if days_since_last_activity >= 14 and (docs_prev_7d > 0 or amount_prev_7d > 0):
signals.append(("inactive_company", 85, "high", f"Нет активности по компании {counterparty} уже {days_since_last_activity} дн."))
if amount_prev_7d > 0 and amount_7d < amount_prev_7d * 0.5:
signals.append(("amount_drop", 70, "high", f"Объём по компании {counterparty} упал более чем на 50% неделя к неделе."))
if docs_prev_7d > 0 and docs_7d == 0:
signals.append(("docs_stopped", 55, "medium", f"По компании {counterparty} прекратился поток документов за последние 7 дней."))
if open_cases_total > 0:
signals.append(("open_cases", min(95, 40 + open_cases_total * 10), severity_score_to_label(min(95, 40 + open_cases_total * 10)), f"По компании {counterparty} есть открытые кейсы: {open_cases_total}."))
if detections_total > 0:
signals.append(("open_detections", min(90, 35 + detections_total * 5), severity_score_to_label(min(90, 35 + detections_total * 5)), f"По компании {counterparty} есть активные detections: {detections_total}."))
for signal_type, score, severity, summary in signals:
signal_rows.append(
[
generated_at,
infobase,
counterparty,
f"{signal_type}:{infobase}:{counterparty}",
severity,
int(score),
signal_type,
summary,
float(amount_7d),
float(amount_prev_7d),
int(docs_7d),
int(docs_prev_7d),
int(max(days_since_last_activity, 0)),
int(open_cases_total),
int(detections_total),
]
)
if forecast_rows:
client.insert(
"analytics_1c.company_forecasts",
forecast_rows,
column_names=[
"generated_at",
"as_of_date",
"infobase",
"counterparty",
"horizon_days",
"metric",
"baseline_daily",
"trend_slope",
"predicted_daily",
"predicted_total",
"confidence",
"model",
"source_days",
"note",
],
)
if signal_rows:
client.insert(
"analytics_1c.company_health_signals",
signal_rows,
column_names=[
"generated_at",
"infobase",
"counterparty",
"signal_id",
"severity",
"score",
"signal_type",
"summary",
"amount_7d",
"amount_prev_7d",
"docs_7d",
"docs_prev_7d",
"days_since_last_activity",
"open_cases_total",
"detections_total",
],
)
print(
f"company intelligence refreshed: forecasts={len(forecast_rows)} signals={len(signal_rows)} generated_at={generated_at.isoformat()}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+3
View File
@@ -0,0 +1,3 @@
fastapi==0.115.2
uvicorn[standard]==0.32.0
clickhouse-connect==0.7.16