389 lines
16 KiB
Python
389 lines
16 KiB
Python
#!/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
|
|
|
|
import clickhouse_connect
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
PROMPT_PATH = ROOT / "ai" / "recovery_brief_prompt.md"
|
|
SCHEMA_PATH = ROOT / "ai" / "recovery_brief_schema.json"
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
p = argparse.ArgumentParser(description="Generate recovery 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(
|
|
"--brief-state-dir",
|
|
default=os.getenv("AW_1C_MANAGER_BRIEF_STATE_DIR", str(ROOT / "state" / "manager-brief")),
|
|
)
|
|
p.add_argument(
|
|
"--weekly-digest-state-dir",
|
|
default=os.getenv("AW_1C_WEEKLY_DIGEST_STATE_DIR", str(ROOT / "state" / "weekly-digest")),
|
|
)
|
|
p.add_argument(
|
|
"--state-dir",
|
|
default=os.getenv("AW_1C_RECOVERY_BRIEF_STATE_DIR", str(ROOT / "state" / "recovery-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(
|
|
"--timeout-sec",
|
|
type=int,
|
|
default=int(os.getenv("AW_1C_MANAGER_BRIEF_TIMEOUT_SEC", "300")),
|
|
)
|
|
p.add_argument(
|
|
"--top-limit",
|
|
type=int,
|
|
default=int(os.getenv("AW_1C_RECOVERY_BRIEF_TOP_LIMIT", "6")),
|
|
)
|
|
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 q(value: str) -> str:
|
|
return "'" + value.replace("'", "''") + "'"
|
|
|
|
|
|
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 load_text(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def write_text(path: Path, content: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(content, encoding="utf-8")
|
|
|
|
|
|
def write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def load_json_if_exists(path: Path) -> dict[str, Any] | None:
|
|
if not path.exists():
|
|
return None
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def load_latest_json(state_dir: Path) -> dict[str, Any] | None:
|
|
return load_json_if_exists(state_dir / "latest.json")
|
|
|
|
|
|
def build_context(client, args: argparse.Namespace) -> dict[str, Any]:
|
|
latest_brief = load_latest_json(Path(args.brief_state_dir)) or {}
|
|
latest_weekly_digest = load_latest_json(Path(args.weekly_digest_state_dir)) or {}
|
|
|
|
problematic = rows_to_dict(
|
|
client.query(
|
|
f"""
|
|
WITH recent AS (
|
|
SELECT
|
|
infobase,
|
|
counterparty,
|
|
max(generated_at) AS latest_signal_at,
|
|
max(score) AS max_score,
|
|
sum(score) AS total_score,
|
|
count() AS signals_total,
|
|
countIf(severity = 'critical') AS critical_total,
|
|
countIf(severity = 'high') AS high_total,
|
|
argMax(severity, tuple(score, generated_at)) AS top_severity,
|
|
argMax(signal_type, tuple(score, generated_at)) AS top_signal_type,
|
|
argMax(summary, tuple(score, generated_at)) AS top_summary
|
|
FROM analytics_1c.company_health_signals
|
|
WHERE generated_at >= now() - INTERVAL 7 DAY
|
|
GROUP BY infobase, counterparty
|
|
)
|
|
SELECT
|
|
p.infobase AS infobase,
|
|
p.company_entity_key AS company_entity_key,
|
|
p.counterparty AS counterparty,
|
|
p.source_counterparty,
|
|
p.company_name,
|
|
p.normalized_counterparty,
|
|
p.registry_match_mode,
|
|
p.registry_assignee_name,
|
|
p.registry_status,
|
|
p.signal_severity,
|
|
p.signal_score,
|
|
round(p.amount_30d, 2) AS amount_30d,
|
|
round(p.amount_forecast_30d, 2) AS amount_forecast_30d,
|
|
p.current_status,
|
|
p.active_locks,
|
|
p.open_cases_total,
|
|
p.detections_total,
|
|
r.latest_signal_at,
|
|
r.max_score,
|
|
r.total_score,
|
|
r.signals_total,
|
|
r.critical_total,
|
|
r.high_total,
|
|
r.top_severity,
|
|
r.top_signal_type,
|
|
r.top_summary
|
|
FROM recent AS r
|
|
INNER JOIN analytics_1c.v_company_portfolio_overview AS p
|
|
ON p.infobase = r.infobase
|
|
AND p.counterparty = r.counterparty
|
|
ORDER BY r.max_score DESC, r.signals_total DESC, p.open_cases_total DESC, p.amount_30d DESC, p.counterparty
|
|
LIMIT {int(args.top_limit)}
|
|
"""
|
|
)
|
|
)
|
|
|
|
return {
|
|
"generated_at": datetime.now(UTC).isoformat(),
|
|
"latest_brief": {
|
|
"generated_at": latest_brief.get("generated_at"),
|
|
"headline": latest_brief.get("brief", {}).get("headline"),
|
|
"summary": latest_brief.get("brief", {}).get("summary", []),
|
|
"delta": latest_brief.get("context", {}).get("delta", {}),
|
|
"portfolio_summary": latest_brief.get("context", {}).get("portfolio_summary", {}),
|
|
},
|
|
"latest_weekly_digest": {
|
|
"generated_at": latest_weekly_digest.get("generated_at"),
|
|
"headline": latest_weekly_digest.get("digest", {}).get("headline"),
|
|
"summary": latest_weekly_digest.get("digest", {}).get("summary", []),
|
|
"top_priorities": latest_weekly_digest.get("digest", {}).get("top_priorities", []),
|
|
},
|
|
"problematic_companies": problematic,
|
|
}
|
|
|
|
|
|
def render_deterministic_recovery(context: dict[str, Any]) -> dict[str, Any]:
|
|
latest_brief = context.get("latest_brief", {})
|
|
portfolio = latest_brief.get("portfolio_summary", {})
|
|
delta = latest_brief.get("delta", {})
|
|
problematic = context.get("problematic_companies", [])
|
|
|
|
headline = (
|
|
f"Recovery-контур: хвост кейсов {portfolio.get('open_cases_total', 0)}, "
|
|
f"critical {portfolio.get('critical_total', 0)}/{portfolio.get('companies_total', 0)}."
|
|
)
|
|
situation = [
|
|
f"Открытых кейсов {portfolio.get('open_cases_total', 0)}, detections {portfolio.get('detections_total', 0)}.",
|
|
f"С последнего запуска: open cases {int(delta.get('summary', {}).get('open_cases_total_delta', 0) or 0):+d}, detections {int(delta.get('summary', {}).get('detections_total_delta', 0) or 0):+d}.",
|
|
"Проблема в накоплении operational-хвоста, а не в резком обвале активности портфеля.",
|
|
]
|
|
portfolio_actions = [
|
|
"Сжать фокус до 5–6 компаний первой очереди и перестать размазывать контроль по всему портфелю.",
|
|
"По каждой компании первой очереди фиксировать только: причина, владелец, срок, факт снижения кейсов.",
|
|
"Сначала убирать рост открытых кейсов и блокировок, а не обсуждать общий красный фон.",
|
|
]
|
|
top_incidents = []
|
|
for item in problematic[:6]:
|
|
company = str(item.get("counterparty") or item.get("company_name") or "-")
|
|
actions = [
|
|
"Проверить владельца и состав открытых кейсов по компании.",
|
|
"Подтвердить, что по компании есть план снижения хвоста в ближайшие 24 часа.",
|
|
]
|
|
if int(item.get("active_locks") or 0) > 0:
|
|
actions.append("Проверить busy/lock-контур базы и не держать блокировки без владельца.")
|
|
if item.get("registry_match_mode") == "manual":
|
|
actions.append("Сначала подтвердить корректность manual-сопоставления.")
|
|
top_incidents.append(
|
|
{
|
|
"company_entity_key": str(item.get("company_entity_key") or ""),
|
|
"company": company,
|
|
"severity": str(item.get("signal_severity") or item.get("top_severity") or "critical"),
|
|
"diagnosis": (
|
|
f"Открытые кейсы {item.get('open_cases_total')}, detections {item.get('detections_total')}, "
|
|
f"блокировки {item.get('active_locks')}, top signal: {item.get('top_signal_type') or '-'}."
|
|
),
|
|
"actions": actions[:4],
|
|
"stop_doing": (
|
|
"Не разбирать компанию общими совещаниями без владельца и без числовой цели на день."
|
|
),
|
|
"target_state_24h": (
|
|
f"Снижение открытых кейсов ниже {max(int(item.get('open_cases_total') or 0) - 3, 0)} и отсутствие нового прироста по следующему запуску."
|
|
),
|
|
}
|
|
)
|
|
caveats = [
|
|
"Operational severity не равна финансовому кризису портфеля.",
|
|
"Manual/alias сопоставления нельзя трактовать как окончательное юридическое соответствие без проверки.",
|
|
]
|
|
return {
|
|
"headline": headline,
|
|
"situation": situation[:6],
|
|
"portfolio_actions": portfolio_actions[:6],
|
|
"top_incidents": top_incidents,
|
|
"caveats": caveats[:5],
|
|
}
|
|
|
|
|
|
def render_markdown(payload: dict[str, Any], generated_at: str) -> str:
|
|
lines = [
|
|
"# Recovery Brief 1C",
|
|
"",
|
|
f"_Сформировано: {generated_at}_",
|
|
"",
|
|
"## Заголовок",
|
|
payload["headline"],
|
|
"",
|
|
"## Ситуация",
|
|
]
|
|
for item in payload["situation"]:
|
|
lines.append(f"- {item}")
|
|
lines.extend(["", "## Действия по портфелю"])
|
|
for item in payload["portfolio_actions"]:
|
|
lines.append(f"- {item}")
|
|
lines.extend(["", "## Компании первой очереди"])
|
|
for idx, item in enumerate(payload["top_incidents"], start=1):
|
|
lines.append(f"{idx}. {item['company']} [{item['severity']}] — {item['diagnosis']}")
|
|
for action in item["actions"]:
|
|
lines.append(f" - {action}")
|
|
lines.append(f" - Стоп: {item['stop_doing']}")
|
|
lines.append(f" - Цель 24ч: {item['target_state_24h']}")
|
|
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-recovery-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)
|
|
write_json(state_dir / "latest.json", artifact)
|
|
write_text(state_dir / "latest.md", markdown)
|
|
write_json(history_dir / f"{timestamp}.json", artifact)
|
|
write_text(history_dir / f"{timestamp}.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, args)
|
|
prompt = build_prompt(context)
|
|
|
|
render_mode = "deterministic"
|
|
model = "deterministic"
|
|
payload = render_deterministic_recovery(context)
|
|
codex_output = ""
|
|
|
|
try:
|
|
rc, stdout_stderr, reply = run_codex(prompt, args)
|
|
codex_output = stdout_stderr
|
|
if rc == 0 and reply:
|
|
candidate = json.loads(reply)
|
|
if candidate:
|
|
payload = candidate
|
|
render_mode = "codex"
|
|
model = args.model
|
|
except Exception as exc: # noqa: BLE001
|
|
codex_output = f"{codex_output}\nFALLBACK: {exc}".strip()
|
|
|
|
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": model,
|
|
"context": context,
|
|
"recovery": 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())
|