feat(1c): add company intelligence forecasting layer
This commit is contained in:
@@ -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())
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi==0.115.2
|
||||
uvicorn[standard]==0.32.0
|
||||
clickhouse-connect==0.7.16
|
||||
Reference in New Issue
Block a user