feat(1c): add codex-based manager brief pipeline
This commit is contained in:
@@ -19,6 +19,15 @@
|
||||
aw_file_1c_grafana_port: 3300
|
||||
aw_file_1c_company_api_host: "{{ ansible_host | default('127.0.0.1') }}"
|
||||
aw_file_1c_company_api_port: 8710
|
||||
aw_file_1c_manager_brief_state_dir: /opt/activitywatch/clickhouse-1c/state/manager-brief
|
||||
aw_file_1c_manager_brief_model: gpt-5.3-codex
|
||||
aw_file_1c_manager_brief_codex_user: codex
|
||||
aw_file_1c_manager_brief_codex_bin: codex
|
||||
aw_file_1c_manager_brief_workdir: /home/codex/infra-admin
|
||||
aw_file_1c_manager_brief_top_limit: 5
|
||||
aw_file_1c_manager_brief_freshness_hours: 8
|
||||
aw_file_1c_manager_brief_timeout_sec: 300
|
||||
aw_file_1c_manager_brief_run_after_ingest: false
|
||||
aw_file_1c_windows_upload_pubkey_path: /tmp/awops_ed25519.pub
|
||||
|
||||
tasks:
|
||||
@@ -106,6 +115,7 @@
|
||||
- check_ingest_freshness.sh
|
||||
- run_company_intelligence_api.sh
|
||||
- run_company_intelligence_refresh.sh
|
||||
- run_manager_brief.sh
|
||||
- run_ingest_cycle.sh
|
||||
|
||||
- name: Создать .env для file-1C analytics
|
||||
@@ -126,6 +136,15 @@
|
||||
CLICKHOUSE_HOST=clickhouse
|
||||
AW_1C_COMPANY_API_HOST={{ aw_file_1c_company_api_host }}
|
||||
AW_1C_COMPANY_API_PORT={{ aw_file_1c_company_api_port }}
|
||||
AW_1C_MANAGER_BRIEF_STATE_DIR={{ aw_file_1c_manager_brief_state_dir }}
|
||||
AW_1C_MANAGER_BRIEF_MODEL={{ aw_file_1c_manager_brief_model }}
|
||||
AW_1C_MANAGER_BRIEF_CODEX_USER={{ aw_file_1c_manager_brief_codex_user }}
|
||||
AW_1C_MANAGER_BRIEF_CODEX_BIN={{ aw_file_1c_manager_brief_codex_bin }}
|
||||
AW_1C_MANAGER_BRIEF_WORKDIR={{ aw_file_1c_manager_brief_workdir }}
|
||||
AW_1C_MANAGER_BRIEF_TOP_LIMIT={{ aw_file_1c_manager_brief_top_limit }}
|
||||
AW_1C_MANAGER_BRIEF_FRESHNESS_HOURS={{ aw_file_1c_manager_brief_freshness_hours }}
|
||||
AW_1C_MANAGER_BRIEF_TIMEOUT_SEC={{ aw_file_1c_manager_brief_timeout_sec }}
|
||||
AW_1C_MANAGER_BRIEF_RUN_AFTER_INGEST={{ 1 if aw_file_1c_manager_brief_run_after_ingest else 0 }}
|
||||
|
||||
- name: Создать etl/config.yml для file-1C analytics
|
||||
ansible.builtin.copy:
|
||||
@@ -254,6 +273,24 @@
|
||||
mode: "0644"
|
||||
notify: Перезагрузить systemd
|
||||
|
||||
- name: Установить systemd unit aw-1c-manager-brief.service
|
||||
ansible.builtin.copy:
|
||||
src: "{{ aw_file_1c_repo_root }}/clickhouse-1c/ops/aw-1c-manager-brief.service"
|
||||
dest: /etc/systemd/system/aw-1c-manager-brief.service
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Перезагрузить systemd
|
||||
|
||||
- name: Установить systemd unit aw-1c-manager-brief.timer
|
||||
ansible.builtin.copy:
|
||||
src: "{{ aw_file_1c_repo_root }}/clickhouse-1c/ops/aw-1c-manager-brief.timer"
|
||||
dest: /etc/systemd/system/aw-1c-manager-brief.timer
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Перезагрузить systemd
|
||||
|
||||
- name: Разрешить Windows upload key для igor
|
||||
ansible.builtin.lineinfile:
|
||||
path: /home/igor/.ssh/authorized_keys
|
||||
@@ -284,6 +321,13 @@
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
||||
- name: Включить и запустить aw-1c-manager-brief.timer
|
||||
ansible.builtin.systemd:
|
||||
name: aw-1c-manager-brief.timer
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
||||
- name: Проверить доступность ClickHouse ping
|
||||
ansible.builtin.uri:
|
||||
url: "http://127.0.0.1:{{ aw_file_1c_clickhouse_port }}/ping"
|
||||
|
||||
@@ -13,3 +13,12 @@ AW_1C_COMPANY_API_PORT=8710
|
||||
AW_1C_COMPANY_LOOKBACK_DAYS=30
|
||||
AW_1C_COMPANY_MIN_DAYS=1
|
||||
AW_1C_COMPANY_HORIZONS=7,30
|
||||
AW_1C_MANAGER_BRIEF_STATE_DIR=/opt/activitywatch/clickhouse-1c/state/manager-brief
|
||||
AW_1C_MANAGER_BRIEF_MODEL=gpt-5.3-codex
|
||||
AW_1C_MANAGER_BRIEF_CODEX_USER=codex
|
||||
AW_1C_MANAGER_BRIEF_CODEX_BIN=codex
|
||||
AW_1C_MANAGER_BRIEF_WORKDIR=/home/codex/infra-admin
|
||||
AW_1C_MANAGER_BRIEF_TOP_LIMIT=5
|
||||
AW_1C_MANAGER_BRIEF_FRESHNESS_HOURS=8
|
||||
AW_1C_MANAGER_BRIEF_TIMEOUT_SEC=300
|
||||
AW_1C_MANAGER_BRIEF_RUN_AFTER_INGEST=0
|
||||
|
||||
+26
-1
@@ -58,6 +58,9 @@ File 1C + reglog + host telemetry
|
||||
- `ai/INVESTIGATOR_API.md` — контракт AI Investigator поверх ClickHouse/cases.
|
||||
- `ai/refresh_company_intelligence.py` — materialization forecast/signals по `counterparty`.
|
||||
- `ai/company_intelligence_api.py` — read-only API для AI/аналитики по компаниям.
|
||||
- `ai/generate_manager_brief.py` — executive brief для руководителя поверх live company intelligence.
|
||||
- `ai/manager_brief_prompt.md` — prompt для локального `codex exec`.
|
||||
- `ai/manager_brief_schema.json` — строгая schema structured-brief ответа.
|
||||
|
||||
## Когда использовать именно этот контур
|
||||
|
||||
@@ -124,9 +127,31 @@ python ai/refresh_company_intelligence.py --host localhost --port 8123 --user de
|
||||
python ai/company_intelligence_api.py --host 127.0.0.1 --port 8710
|
||||
```
|
||||
|
||||
8. В Grafana строить dashboards из `grafana/dashboard-catalog.md` и
|
||||
8. Сформировать executive brief:
|
||||
|
||||
```bash
|
||||
python ai/generate_manager_brief.py --host localhost --port 8123 --user default --password change-me --database analytics_1c
|
||||
```
|
||||
|
||||
9. В Grafana строить dashboards из `grafana/dashboard-catalog.md` и
|
||||
`grafana/query-pack.sql`.
|
||||
|
||||
## Manager brief
|
||||
|
||||
Этот слой делает не raw LLM-чат, а промышленный pipeline:
|
||||
|
||||
- строит компактный context из `v_company_portfolio_overview`;
|
||||
- вызывает локальный `codex exec` на `10.10.10.2`;
|
||||
- валидирует structured output по JSON schema;
|
||||
- при сбое `codex` отдаёт deterministic fallback, чтобы контур не пустел;
|
||||
- пишет `latest.json` и `latest.md` в `state/manager-brief/`.
|
||||
|
||||
Read-only API для руководителя:
|
||||
|
||||
- `GET /api/1/analytics-1c/manager/brief/latest`
|
||||
- `GET /api/1/analytics-1c/manager/brief/latest.md`
|
||||
- `GET /api/1/analytics-1c/manager/brief/history`
|
||||
|
||||
## Ожидаемые источники данных
|
||||
|
||||
- выгрузки 1С по документам;
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import clickhouse_connect
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
@@ -48,6 +51,21 @@ def ch_client():
|
||||
)
|
||||
|
||||
|
||||
def manager_brief_state_dir() -> Path:
|
||||
root = Path(os.getenv("AW_1C_ROOT", "/opt/activitywatch/clickhouse-1c"))
|
||||
configured = os.getenv("AW_1C_MANAGER_BRIEF_STATE_DIR")
|
||||
if configured:
|
||||
return Path(configured)
|
||||
return root / "state" / "manager-brief"
|
||||
|
||||
|
||||
def load_latest_manager_brief() -> dict[str, Any]:
|
||||
latest_path = manager_brief_state_dir() / "latest.json"
|
||||
if not latest_path.exists():
|
||||
raise HTTPException(status_code=404, detail="manager brief not generated yet")
|
||||
return json.loads(latest_path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
app = FastAPI(title="AW-rus 1C Company Intelligence API", version="1.0.0")
|
||||
|
||||
|
||||
@@ -229,6 +247,39 @@ def company_timeline(
|
||||
return {"items": rows, "count": len(rows)}
|
||||
|
||||
|
||||
@app.get("/api/1/analytics-1c/manager/brief/latest")
|
||||
def manager_brief_latest() -> dict[str, Any]:
|
||||
return load_latest_manager_brief()
|
||||
|
||||
|
||||
@app.get("/api/1/analytics-1c/manager/brief/latest.md", response_class=PlainTextResponse)
|
||||
def manager_brief_latest_markdown() -> str:
|
||||
latest_md = manager_brief_state_dir() / "latest.md"
|
||||
if not latest_md.exists():
|
||||
raise HTTPException(status_code=404, detail="manager brief markdown not generated yet")
|
||||
return latest_md.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@app.get("/api/1/analytics-1c/manager/brief/history")
|
||||
def manager_brief_history(limit: int = Query(default=20, ge=1, le=200)) -> dict[str, Any]:
|
||||
history_dir = manager_brief_state_dir() / "history"
|
||||
if not history_dir.exists():
|
||||
return {"items": [], "count": 0}
|
||||
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"))
|
||||
items.append(
|
||||
{
|
||||
"generated_at": payload.get("generated_at"),
|
||||
"render_mode": payload.get("render_mode"),
|
||||
"model": payload.get("model"),
|
||||
"headline": payload.get("brief", {}).get("headline", ""),
|
||||
"path": path.name,
|
||||
}
|
||||
)
|
||||
return {"items": items, "count": len(items)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
uvicorn.run(app, host=args.host, port=args.port)
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
#!/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 decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PROMPT_PATH = ROOT / "ai" / "manager_brief_prompt.md"
|
||||
SCHEMA_PATH = ROOT / "ai" / "manager_brief_schema.json"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="Generate executive manager brief 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(
|
||||
"--state-dir",
|
||||
default=os.getenv("AW_1C_MANAGER_BRIEF_STATE_DIR", str(ROOT / "state" / "manager-brief")),
|
||||
)
|
||||
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(
|
||||
"--top-limit",
|
||||
type=int,
|
||||
default=int(os.getenv("AW_1C_MANAGER_BRIEF_TOP_LIMIT", "5")),
|
||||
)
|
||||
p.add_argument(
|
||||
"--freshness-hours",
|
||||
type=int,
|
||||
default=int(os.getenv("AW_1C_MANAGER_BRIEF_FRESHNESS_HOURS", "8")),
|
||||
)
|
||||
p.add_argument(
|
||||
"--timeout-sec",
|
||||
type=int,
|
||||
default=int(os.getenv("AW_1C_MANAGER_BRIEF_TIMEOUT_SEC", "300")),
|
||||
)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
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(args: argparse.Namespace):
|
||||
import clickhouse_connect
|
||||
|
||||
return clickhouse_connect.get_client(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
username=args.user,
|
||||
password=args.password,
|
||||
database=args.database,
|
||||
)
|
||||
|
||||
|
||||
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 q(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def build_context(client, top_limit: int, freshness_hours: int) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
|
||||
portfolio_summary = rows_to_dict(
|
||||
client.query(
|
||||
"""
|
||||
SELECT
|
||||
count() AS companies_total,
|
||||
countIf(signal_severity = 'critical') AS critical_total,
|
||||
countIf(signal_severity = 'high') AS high_total,
|
||||
countIf(signal_severity = 'medium') AS medium_total,
|
||||
countIf(signal_severity = 'low') AS low_total,
|
||||
countIf(signal_severity = 'none') AS none_total,
|
||||
countIf(registry_match_mode = 'direct') AS direct_total,
|
||||
countIf(registry_match_mode = 'alias') AS alias_total,
|
||||
countIf(registry_match_mode = 'manual') AS manual_total,
|
||||
countIf(registry_match_mode = 'none') AS unmatched_total,
|
||||
countIf(days_since_last_activity >= 7) AS stale_7d_total,
|
||||
countIf(days_since_last_activity >= 14) AS stale_14d_total,
|
||||
countIf(current_status = 'busy' OR active_locks > 0 OR temp_db_present > 0) AS busy_total,
|
||||
round(sum(amount_30d), 2) AS activity_30d_total,
|
||||
round(sum(amount_forecast_30d), 2) AS activity_forecast_30d_total,
|
||||
sum(open_cases_total) AS open_cases_total,
|
||||
sum(detections_total) AS detections_total
|
||||
FROM analytics_1c.v_company_portfolio_overview
|
||||
"""
|
||||
)
|
||||
)[0]
|
||||
|
||||
freshness = rows_to_dict(
|
||||
client.query(
|
||||
"""
|
||||
SELECT
|
||||
(SELECT max(ts) FROM analytics_1c.documents) AS documents_ts,
|
||||
(SELECT max(ts) FROM analytics_1c.companies) AS companies_ts,
|
||||
(SELECT max(ts) FROM analytics_1c.reglog_events) AS reglog_ts,
|
||||
(SELECT max(ts) FROM analytics_1c.audit_events) AS audit_ts,
|
||||
(SELECT max(ts) FROM analytics_1c.host_events) AS host_ts,
|
||||
(SELECT max(generated_at) FROM analytics_1c.company_forecasts) AS forecasts_ts,
|
||||
(SELECT max(generated_at) FROM analytics_1c.company_health_signals) AS signals_ts
|
||||
"""
|
||||
)
|
||||
)[0]
|
||||
freshness_items: list[dict[str, Any]] = []
|
||||
for source, ts in freshness.items():
|
||||
lag_hours = None
|
||||
stale = True
|
||||
if isinstance(ts, str):
|
||||
parsed = datetime.fromisoformat(ts)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
lag_hours = round((now - parsed).total_seconds() / 3600, 2)
|
||||
stale = lag_hours > freshness_hours
|
||||
ts = parsed.isoformat()
|
||||
freshness_items.append(
|
||||
{
|
||||
"source": source,
|
||||
"latest_ts": ts,
|
||||
"lag_hours": lag_hours,
|
||||
"stale": stale,
|
||||
}
|
||||
)
|
||||
|
||||
top_risks = rows_to_dict(
|
||||
client.query(
|
||||
f"""
|
||||
SELECT
|
||||
infobase,
|
||||
counterparty,
|
||||
normalized_counterparty,
|
||||
registry_match_mode,
|
||||
registry_assignee_name,
|
||||
signal_severity,
|
||||
signal_score,
|
||||
top_signal,
|
||||
current_status,
|
||||
active_locks,
|
||||
open_cases_total,
|
||||
detections_total,
|
||||
days_since_last_activity,
|
||||
round(amount_30d, 2) AS amount_30d,
|
||||
round(amount_forecast_30d, 2) AS amount_forecast_30d
|
||||
FROM analytics_1c.v_company_portfolio_overview
|
||||
ORDER BY signal_score DESC, open_cases_total DESC, detections_total DESC, amount_30d DESC, counterparty
|
||||
LIMIT {int(top_limit)}
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
top_forecasts = rows_to_dict(
|
||||
client.query(
|
||||
f"""
|
||||
SELECT
|
||||
infobase,
|
||||
counterparty,
|
||||
normalized_counterparty,
|
||||
registry_match_mode,
|
||||
signal_severity,
|
||||
signal_score,
|
||||
round(amount_30d, 2) AS amount_30d,
|
||||
round(amount_forecast_30d, 2) AS amount_forecast_30d,
|
||||
round(docs_forecast_30d, 2) AS docs_forecast_30d,
|
||||
amount_forecast_confidence,
|
||||
top_signal
|
||||
FROM analytics_1c.v_company_portfolio_overview
|
||||
ORDER BY amount_forecast_30d DESC, signal_score DESC, amount_30d DESC, counterparty
|
||||
LIMIT {int(top_limit)}
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
watchlist = rows_to_dict(
|
||||
client.query(
|
||||
f"""
|
||||
SELECT
|
||||
s.infobase,
|
||||
s.counterparty,
|
||||
p.normalized_counterparty,
|
||||
p.registry_match_mode,
|
||||
s.signal_type,
|
||||
s.severity,
|
||||
s.score,
|
||||
s.summary,
|
||||
p.days_since_last_activity,
|
||||
round(p.amount_30d, 2) AS amount_30d,
|
||||
round(p.amount_forecast_30d, 2) AS amount_forecast_30d
|
||||
FROM analytics_1c.v_company_health_current AS s
|
||||
LEFT JOIN analytics_1c.v_company_portfolio_overview AS p
|
||||
ON p.infobase = s.infobase AND p.counterparty = s.counterparty
|
||||
WHERE s.signal_type IN ('inactive_company', 'amount_drop', 'docs_stopped')
|
||||
ORDER BY s.score DESC, p.days_since_last_activity DESC, p.amount_30d DESC
|
||||
LIMIT {int(top_limit)}
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
busy_bases = rows_to_dict(
|
||||
client.query(
|
||||
f"""
|
||||
SELECT
|
||||
infobase,
|
||||
counterparty,
|
||||
normalized_counterparty,
|
||||
current_status,
|
||||
active_locks,
|
||||
temp_db_present,
|
||||
scheduler_touched,
|
||||
current_activity_score,
|
||||
signal_severity,
|
||||
signal_score
|
||||
FROM analytics_1c.v_company_portfolio_overview
|
||||
WHERE current_status = 'busy' OR active_locks > 0 OR temp_db_present > 0
|
||||
ORDER BY active_locks DESC, temp_db_present DESC, current_activity_score DESC, counterparty
|
||||
LIMIT {int(top_limit)}
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
recent_cases = rows_to_dict(
|
||||
client.query(
|
||||
f"""
|
||||
SELECT
|
||||
c.opened_at,
|
||||
c.infobase,
|
||||
c.entity_id AS counterparty,
|
||||
c.title,
|
||||
c.severity,
|
||||
c.status
|
||||
FROM analytics_1c.cases AS c
|
||||
WHERE c.entity_type = 'counterparty' AND c.status != 'closed'
|
||||
ORDER BY c.opened_at DESC
|
||||
LIMIT {int(top_limit)}
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"generated_at": now.isoformat(),
|
||||
"freshness_threshold_hours": freshness_hours,
|
||||
"portfolio_summary": portfolio_summary,
|
||||
"freshness": freshness_items,
|
||||
"top_risks": top_risks,
|
||||
"top_forecasts": top_forecasts,
|
||||
"watchlist": watchlist,
|
||||
"busy_bases": busy_bases,
|
||||
"recent_cases": recent_cases,
|
||||
}
|
||||
|
||||
|
||||
def render_deterministic_payload(context: dict[str, Any]) -> dict[str, Any]:
|
||||
summary = context["portfolio_summary"]
|
||||
freshness = context["freshness"]
|
||||
stale_sources = [item["source"] for item in freshness if item["stale"]]
|
||||
top_risks = context["top_risks"][:5]
|
||||
top_forecasts = context["top_forecasts"][:5]
|
||||
watchlist = context["watchlist"][:5]
|
||||
|
||||
headline = (
|
||||
f"Портфель {summary['companies_total']} компаний: критичных {summary['critical_total']}, "
|
||||
f"high {summary['high_total']}, stale 14д {summary['stale_14d_total']}."
|
||||
)
|
||||
summary_lines = [
|
||||
f"Покрытие реестра полное: direct {summary['direct_total']}, alias {summary['alias_total']}, manual {summary['manual_total']}, unmatched {summary['unmatched_total']}.",
|
||||
f"Суммарная активность за 30 дней {summary['activity_30d_total']}, прогнозная активность на 30 дней {summary['activity_forecast_30d_total']}.",
|
||||
f"Открытых кейсов по компаниям {summary['open_cases_total']}, активных detections {summary['detections_total']}.",
|
||||
]
|
||||
if stale_sources:
|
||||
summary_lines.append(f"Есть просрочка по источникам: {', '.join(stale_sources)}.")
|
||||
else:
|
||||
summary_lines.append("Свежесть источников укладывается в заданный порог.")
|
||||
|
||||
risk_items = [
|
||||
{
|
||||
"company": item["counterparty"],
|
||||
"severity": item["signal_severity"],
|
||||
"reason": item["top_signal"] or "Повышенный signal score без детализации top_signal.",
|
||||
"recommended_action": (
|
||||
"Проверить открытые кейсы, detections и фактическую занятость файловой базы."
|
||||
if item["open_cases_total"] or item["detections_total"] or item["active_locks"]
|
||||
else "Проверить последние события по компании и причину роста operational severity."
|
||||
),
|
||||
}
|
||||
for item in top_risks
|
||||
]
|
||||
|
||||
forecast_items = [
|
||||
{
|
||||
"company": item["counterparty"],
|
||||
"forecast_30d": str(item["amount_forecast_30d"]),
|
||||
"interpretation": (
|
||||
f"Текущая активность 30д {item['amount_30d']}, match {item['registry_match_mode']}, severity {item['signal_severity']}."
|
||||
),
|
||||
}
|
||||
for item in top_forecasts
|
||||
]
|
||||
|
||||
actions = [
|
||||
"Разобрать компании с открытыми кейсами и максимальным signal score в первую очередь.",
|
||||
"Проверить watchlist по inactivity/amount_drop/docs_stopped и подтвердить, это бизнес-пауза или operational сбой.",
|
||||
"Отдельно пройти по manual-match компаниям перед управленческими выводами из реестра.",
|
||||
]
|
||||
if watchlist:
|
||||
actions[1] = (
|
||||
f"Проверить watchlist: {', '.join(item['counterparty'] for item in watchlist[:3])}."
|
||||
)
|
||||
|
||||
caveats = [
|
||||
"Показатель amount здесь трактуется как activity score, а не как деньги или выручка.",
|
||||
"Severity operational-driven: high/critical отражают кейсы, detections и занятость базы, а не автоматически финансовый риск.",
|
||||
]
|
||||
if summary["manual_total"] > 0:
|
||||
caveats.append("Компании с registry_match_mode=manual требуют осторожности при юридической интерпретации реестра.")
|
||||
|
||||
return {
|
||||
"headline": headline,
|
||||
"summary": summary_lines[:6],
|
||||
"top_risks": risk_items[:5],
|
||||
"top_forecasts": forecast_items[:5],
|
||||
"actions": actions[:5],
|
||||
"caveats": caveats[:4],
|
||||
}
|
||||
|
||||
|
||||
def render_markdown(payload: dict[str, Any], generated_at: str) -> str:
|
||||
lines = [
|
||||
f"# Executive Brief 1C",
|
||||
"",
|
||||
f"_Сформировано: {generated_at}_",
|
||||
"",
|
||||
f"## Заголовок",
|
||||
payload["headline"],
|
||||
"",
|
||||
"## Кратко",
|
||||
]
|
||||
for item in payload["summary"]:
|
||||
lines.append(f"- {item}")
|
||||
lines.extend(["", "## Компании риска"])
|
||||
for idx, item in enumerate(payload["top_risks"], start=1):
|
||||
lines.append(
|
||||
f"{idx}. {item['company']} [{item['severity']}] — {item['reason']} Действие: {item['recommended_action']}"
|
||||
)
|
||||
lines.extend(["", "## Прогноз по активности 30д"])
|
||||
for idx, item in enumerate(payload["top_forecasts"], start=1):
|
||||
lines.append(
|
||||
f"{idx}. {item['company']} — прогноз {item['forecast_30d']}. {item['interpretation']}"
|
||||
)
|
||||
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-manager-brief-", 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 save_artifacts(
|
||||
state_dir: Path,
|
||||
artifact: dict[str, Any],
|
||||
markdown: str,
|
||||
) -> None:
|
||||
timestamp = datetime.fromisoformat(artifact["generated_at"]).strftime("%Y%m%dT%H%M%SZ")
|
||||
history_dir = state_dir / "history"
|
||||
history_dir.mkdir(parents=True, exist_ok=True)
|
||||
latest_json = state_dir / "latest.json"
|
||||
latest_md = state_dir / "latest.md"
|
||||
history_json = history_dir / f"{timestamp}.json"
|
||||
history_md = history_dir / f"{timestamp}.md"
|
||||
write_json(latest_json, artifact)
|
||||
write_text(latest_md, markdown)
|
||||
write_json(history_json, artifact)
|
||||
write_text(history_md, markdown)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
state_dir = Path(args.state_dir)
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
client = ch_client(args)
|
||||
context = build_context(client, top_limit=args.top_limit, freshness_hours=args.freshness_hours)
|
||||
prompt = build_prompt(context)
|
||||
|
||||
codex_rc = None
|
||||
codex_output = ""
|
||||
render_mode = "deterministic"
|
||||
payload: dict[str, Any]
|
||||
|
||||
try:
|
||||
codex_rc, codex_output, codex_reply = run_codex(prompt, args)
|
||||
payload = json.loads(codex_reply) if codex_reply else {}
|
||||
if not payload:
|
||||
raise ValueError("empty codex payload")
|
||||
render_mode = "codex"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
payload = render_deterministic_payload(context)
|
||||
codex_output = f"{codex_output}\nFALLBACK: {exc}".strip()
|
||||
render_mode = "deterministic"
|
||||
|
||||
generated_at = datetime.now(UTC).replace(microsecond=0).isoformat()
|
||||
markdown = render_markdown(payload, generated_at)
|
||||
artifact = {
|
||||
"generated_at": generated_at,
|
||||
"render_mode": render_mode,
|
||||
"model": args.model,
|
||||
"codex_rc": codex_rc,
|
||||
"context": context,
|
||||
"brief": payload,
|
||||
"markdown": markdown,
|
||||
"codex_output_excerpt": codex_output[-4000:] if codex_output else "",
|
||||
}
|
||||
save_artifacts(state_dir, artifact, markdown)
|
||||
print(json.dumps({"status": "ok", "render_mode": render_mode, "state_dir": str(state_dir)}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,18 @@
|
||||
Ты готовишь краткий executive brief для руководителя по портфелю компаний в файловой 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_risks",
|
||||
"top_forecasts",
|
||||
"actions",
|
||||
"caveats"
|
||||
],
|
||||
"properties": {
|
||||
"headline": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 180
|
||||
},
|
||||
"summary": {
|
||||
"type": "array",
|
||||
"minItems": 3,
|
||||
"maxItems": 6,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 260
|
||||
}
|
||||
},
|
||||
"top_risks": {
|
||||
"type": "array",
|
||||
"maxItems": 5,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"company",
|
||||
"severity",
|
||||
"reason",
|
||||
"recommended_action"
|
||||
],
|
||||
"properties": {
|
||||
"company": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 32
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 260
|
||||
},
|
||||
"recommended_action": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 260
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"top_forecasts": {
|
||||
"type": "array",
|
||||
"maxItems": 5,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"company",
|
||||
"forecast_30d",
|
||||
"interpretation"
|
||||
],
|
||||
"properties": {
|
||||
"company": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160
|
||||
},
|
||||
"forecast_30d": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
"interpretation": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 260
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"maxItems": 5,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 260
|
||||
}
|
||||
},
|
||||
"caveats": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 4,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 260
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import generate_manager_brief as gmb
|
||||
|
||||
|
||||
class ManagerBriefTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.context = {
|
||||
"portfolio_summary": {
|
||||
"companies_total": 41,
|
||||
"critical_total": 10,
|
||||
"high_total": 20,
|
||||
"medium_total": 5,
|
||||
"low_total": 3,
|
||||
"none_total": 3,
|
||||
"direct_total": 33,
|
||||
"alias_total": 5,
|
||||
"manual_total": 3,
|
||||
"unmatched_total": 0,
|
||||
"stale_7d_total": 4,
|
||||
"stale_14d_total": 2,
|
||||
"busy_total": 7,
|
||||
"activity_30d_total": 12345.0,
|
||||
"activity_forecast_30d_total": 54321.0,
|
||||
"open_cases_total": 12,
|
||||
"detections_total": 20,
|
||||
},
|
||||
"freshness": [
|
||||
{"source": "documents_ts", "stale": False},
|
||||
{"source": "signals_ts", "stale": True},
|
||||
],
|
||||
"top_risks": [
|
||||
{
|
||||
"counterparty": "ФЕЛИЦТ ГРУПП 2026",
|
||||
"signal_severity": "critical",
|
||||
"top_signal": "Есть открытые кейсы",
|
||||
"open_cases_total": 2,
|
||||
"detections_total": 5,
|
||||
"active_locks": 3,
|
||||
"amount_30d": 100.0,
|
||||
"amount_forecast_30d": 300.0,
|
||||
}
|
||||
],
|
||||
"top_forecasts": [
|
||||
{
|
||||
"counterparty": "АВКО 2026",
|
||||
"amount_30d": 1000.0,
|
||||
"amount_forecast_30d": 3000.0,
|
||||
"registry_match_mode": "direct",
|
||||
"signal_severity": "high",
|
||||
}
|
||||
],
|
||||
"watchlist": [{"counterparty": "АВКО 2026"}],
|
||||
}
|
||||
|
||||
def test_deterministic_payload_shape(self) -> None:
|
||||
payload = gmb.render_deterministic_payload(self.context)
|
||||
self.assertIn("headline", payload)
|
||||
self.assertGreaterEqual(len(payload["summary"]), 3)
|
||||
self.assertEqual(payload["top_risks"][0]["company"], "ФЕЛИЦТ ГРУПП 2026")
|
||||
self.assertIn("manual", " ".join(payload["caveats"]).lower())
|
||||
|
||||
def test_markdown_render(self) -> None:
|
||||
payload = gmb.render_deterministic_payload(self.context)
|
||||
md = gmb.render_markdown(payload, "2026-05-22T12:00:00+00:00")
|
||||
self.assertIn("# Executive Brief 1C", md)
|
||||
self.assertIn("## Компании риска", md)
|
||||
self.assertIn("ФЕЛИЦТ ГРУПП 2026", md)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=AW-rus 1C Manager Executive Brief
|
||||
After=network-online.target aw-1c-company-api.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Environment=AW_1C_ROOT=/opt/activitywatch/clickhouse-1c
|
||||
ExecStart=/opt/activitywatch/clickhouse-1c/ops/run_manager_brief.sh
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Run AW-rus 1C manager brief every 6 hours
|
||||
|
||||
[Timer]
|
||||
OnBootSec=30min
|
||||
OnUnitActiveSec=6h
|
||||
Unit=aw-1c-manager-brief.service
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -17,7 +17,8 @@ mkdir -p \
|
||||
"${ROOT}/archive/registry" \
|
||||
"${ROOT}/archive/reglog" \
|
||||
"${ROOT}/archive/audit" \
|
||||
"${ROOT}/archive/host"
|
||||
"${ROOT}/archive/host" \
|
||||
"${ROOT}/state/manager-brief/history"
|
||||
|
||||
if [[ ! -f "${ROOT}/etl/config.yml" ]]; then
|
||||
cp "${ROOT}/etl/config.example.yml" "${ROOT}/etl/config.yml"
|
||||
|
||||
@@ -65,3 +65,9 @@ docker exec -i "${CH_CONTAINER}" clickhouse-client \
|
||||
--password "${CLICKHOUSE_PASSWORD}" \
|
||||
--database "${CLICKHOUSE_DB}" \
|
||||
< "${ROOT}/detections/open_cases_from_detections.sql"
|
||||
|
||||
if [[ "${AW_1C_MANAGER_BRIEF_RUN_AFTER_INGEST:-0}" == "1" ]]; then
|
||||
if ! "${ROOT}/ops/run_manager_brief.sh"; then
|
||||
echo "warning: manager brief refresh failed after ingest" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/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
|
||||
|
||||
CH_RUNTIME_HOST="${AW_1C_CLICKHOUSE_RUNTIME_HOST:-${CLICKHOUSE_HOST}}"
|
||||
if [[ "${CH_RUNTIME_HOST}" == "clickhouse" ]]; then
|
||||
CH_RUNTIME_HOST="127.0.0.1"
|
||||
fi
|
||||
|
||||
export CLICKHOUSE_HOST="${CH_RUNTIME_HOST}"
|
||||
|
||||
exec "${VENV}/bin/python" "${ROOT}/ai/generate_manager_brief.py" \
|
||||
--host "${CH_RUNTIME_HOST}" \
|
||||
--port "${CLICKHOUSE_PORT}" \
|
||||
--user "${CLICKHOUSE_USER}" \
|
||||
--password "${CLICKHOUSE_PASSWORD}" \
|
||||
--database "${CLICKHOUSE_DB}"
|
||||
@@ -87,6 +87,34 @@ Endpoints:
|
||||
- `GET /api/1/analytics-1c/companies/{counterparty}/summary`
|
||||
- `GET /api/1/analytics-1c/companies/{counterparty}/forecast`
|
||||
- `GET /api/1/analytics-1c/companies/{counterparty}/timeline`
|
||||
- `GET /api/1/analytics-1c/manager/brief/latest`
|
||||
- `GET /api/1/analytics-1c/manager/brief/latest.md`
|
||||
- `GET /api/1/analytics-1c/manager/brief/history`
|
||||
|
||||
### Executive brief для руководителя
|
||||
|
||||
Файлы:
|
||||
|
||||
- `clickhouse-1c/ai/generate_manager_brief.py`
|
||||
- `clickhouse-1c/ai/manager_brief_prompt.md`
|
||||
- `clickhouse-1c/ai/manager_brief_schema.json`
|
||||
- `clickhouse-1c/ops/run_manager_brief.sh`
|
||||
- `clickhouse-1c/ops/aw-1c-manager-brief.service`
|
||||
- `clickhouse-1c/ops/aw-1c-manager-brief.timer`
|
||||
|
||||
Что делает:
|
||||
|
||||
- собирает live context по портфелю компаний из `ClickHouse`;
|
||||
- вызывает локальный `codex exec` на `10.10.10.2` от пользователя `codex`;
|
||||
- требует structured JSON по schema, а не свободный текст;
|
||||
- рендерит итог в `latest.json` и `latest.md`;
|
||||
- при сбое `codex` даёт deterministic fallback, чтобы контур не оставался пустым.
|
||||
|
||||
Важно:
|
||||
|
||||
- `tmux` не является production-зависимостью;
|
||||
- интерактивная сессия `codex` может быть открыта, но pipeline работает через обычный `codex exec`;
|
||||
- service не трогает `1С`, работает только на уже выгруженных read-only данных.
|
||||
|
||||
### Ops
|
||||
|
||||
@@ -133,6 +161,12 @@ clickhouse-client --queries-file clickhouse/init/04_company_intelligence.sql
|
||||
./ops/run_company_intelligence_api.sh
|
||||
```
|
||||
|
||||
### 5. Сформировать manager brief
|
||||
|
||||
```bash
|
||||
./ops/run_manager_brief.sh
|
||||
```
|
||||
|
||||
По умолчанию:
|
||||
|
||||
- host: `127.0.0.1`
|
||||
@@ -151,6 +185,15 @@ clickhouse-client --queries-file clickhouse/init/04_company_intelligence.sql
|
||||
- `AW_1C_COMPANY_LOOKBACK_DAYS`
|
||||
- `AW_1C_COMPANY_MIN_DAYS`
|
||||
- `AW_1C_COMPANY_HORIZONS`
|
||||
- `AW_1C_MANAGER_BRIEF_STATE_DIR`
|
||||
- `AW_1C_MANAGER_BRIEF_MODEL`
|
||||
- `AW_1C_MANAGER_BRIEF_CODEX_USER`
|
||||
- `AW_1C_MANAGER_BRIEF_CODEX_BIN`
|
||||
- `AW_1C_MANAGER_BRIEF_WORKDIR`
|
||||
- `AW_1C_MANAGER_BRIEF_TOP_LIMIT`
|
||||
- `AW_1C_MANAGER_BRIEF_FRESHNESS_HOURS`
|
||||
- `AW_1C_MANAGER_BRIEF_TIMEOUT_SEC`
|
||||
- `AW_1C_MANAGER_BRIEF_RUN_AFTER_INGEST`
|
||||
|
||||
## Что уже есть в live payload
|
||||
|
||||
|
||||
Reference in New Issue
Block a user