chore: remove dead supervisor analytics and archived tooling

This commit is contained in:
Yera All
2026-04-08 15:59:07 +05:00
parent cf824f3fdd
commit bfaec370f9
23 changed files with 0 additions and 6484 deletions
BIN
View File
Binary file not shown.
View File
-1519
View File
File diff suppressed because it is too large Load Diff
-46
View File
@@ -1,46 +0,0 @@
import urllib.request
import json
import urllib.error
def fetch_data():
try:
req = urllib.request.Request(
'http://92.38.48.166:8080/proxy/auth/auth/login',
data=json.dumps({"username": "admin", "password": "admin123"}).encode('utf-8'),
headers={'Content-Type': 'application/json'}
)
login_resp = urllib.request.urlopen(req)
token = json.loads(login_resp.read().decode()).get('access_token')
headers = {'Authorization': f'Bearer {token}'}
calls_req = urllib.request.Request(
'http://92.38.48.166:8080/proxy/asterisk-bridge/asterisk/events?limit=25',
headers=headers
)
calls_resp = urllib.request.urlopen(calls_req)
events = json.loads(calls_resp.read().decode())
if not events:
print("No events found.")
return
for call_id in ["1775500989.2", "1775499861.0"]:
ai_req = urllib.request.Request(
f'http://92.38.48.166:8080/proxy/asterisk-bridge/asterisk/live-calls/{call_id}/ai-summary',
headers=headers
)
try:
ai_resp = urllib.request.urlopen(ai_req)
ai_data = json.loads(ai_resp.read().decode())
print(f"\n----- AI SUMMARY FOR {call_id} -----")
with open(f"e:\\Zhan\\summary_{call_id}.json", "w", encoding="utf-8") as f:
json.dump(ai_data, f, ensure_ascii=False, indent=2)
print(f"Saved to e:\\Zhan\\summary_{call_id}.json")
except urllib.error.HTTPError as e:
print(f"Call {call_id} error: {e}")
except Exception as e:
print(f"Error: {e}")
if __name__ == '__main__':
fetch_data()
-283
View File
@@ -1,283 +0,0 @@
from __future__ import annotations
import argparse
import csv
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GATE4_PATH = ROOT / "docs" / "gates" / "gate-04-pilot-hardening.md"
P1P2_PATH = ROOT / "docs" / "gates" / "p1-p2-defects.md"
ACCEPTED_RELEASE_PATH = ROOT / "docs" / "releases" / "v1.0.0-mvp-accepted.md"
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def iso_now() -> str:
return utc_now().isoformat()
def _normalized_status(value: str) -> str:
return value.strip().lower()
def _is_open_status(value: str) -> bool:
normalized = _normalized_status(value)
return normalized not in {"closed", "resolved", "verified"}
def parse_open_defect_counts(defect_log_path: Path) -> dict[str, int]:
counts = {"P1": 0, "P2": 0, "P3": 0, "P4": 0}
with defect_log_path.open("r", newline="", encoding="utf-8") as handle:
reader = csv.DictReader(handle)
for row in reader:
severity = (row.get("severity") or "").strip().upper()
status = row.get("status") or ""
if severity in counts and _is_open_status(status):
counts[severity] += 1
return counts
def validate_session_protocol(session_protocol_path: Path) -> list[str]:
content = session_protocol_path.read_text(encoding="utf-8")
errors: list[str] = []
blank_markers = [
"- Session ID:",
"- Environment URL:",
"- Build/version:",
"- Deployment date:",
"- Cluster/namespace:",
"- Business owner:",
"- IT owner:",
]
for marker in blank_markers:
if f"{marker}\n" in content or f"{marker}\r\n" in content:
errors.append(f"Session protocol still contains blank field: {marker}")
if "## Decision" not in content or "[x]" not in content:
errors.append("Session protocol does not contain a selected decision checkbox")
return errors
def validate_signoff_sheet(signoff_sheet_path: Path) -> list[str]:
content = signoff_sheet_path.read_text(encoding="utf-8")
errors: list[str] = []
placeholder_lines = [
" - Name:",
" - Signature:",
" - Date:",
]
for placeholder in placeholder_lines:
if content.count(placeholder) > 0:
errors.append(f"Sign-off sheet still contains placeholder lines matching: {placeholder.strip()}")
break
decision_lines = [line for line in content.splitlines() if line.startswith("- [")]
if sum(1 for line in decision_lines if "[x]" in line.lower()) != 1:
errors.append("Sign-off sheet must have exactly one selected decision checkbox")
return errors
def apply_gate4_closure(
content: str,
*,
session_id: str,
session_dir: Path,
acceptance: str,
open_counts: dict[str, int],
) -> str:
updated = content
updated = updated.replace(
"- [ ] UAT signed with real operators/supervisors",
"- [x] UAT signed with real operators/supervisors",
)
updated = updated.replace(
"- [ ] P1/P2 defects closed",
"- [x] P1/P2 defects closed",
)
marker = "Manual closure update:"
if marker in updated:
updated = updated.split(marker, 1)[0].rstrip()
lines = [
"",
"Manual closure update:",
f"- Date (UTC): {iso_now()}",
f"- Session ID: {session_id}",
f"- Session directory: `{session_dir.as_posix()}`",
f"- Acceptance: {acceptance}",
(
"- Remaining open defects: "
f"P1={open_counts['P1']}, P2={open_counts['P2']}, "
f"P3={open_counts['P3']}, P4={open_counts['P4']}"
),
]
return updated.rstrip() + "\n" + "\n".join(lines) + "\n"
def build_p1p2_register(
*,
session_id: str,
session_dir: Path,
open_counts: dict[str, int],
acceptance: str,
) -> str:
lines = [
"# P1/P2 Defect Register (Pilot)",
"",
f"Last update: {utc_now().date().isoformat()} ({session_id})",
"",
"## Current Status",
f"- Open P1: {open_counts['P1']}",
f"- Open P2: {open_counts['P2']}",
"- Source: manual UAT close-out",
f"- Manual session evidence: `{session_dir.as_posix()}`",
"",
"## Triage Policy",
"- Only `P1` and `P2` issues belong to MVP remediation.",
"- `P3` and `P4` issues move to `docs/roadmap/05-wave2-backlog.md` unless they block sign-off.",
"",
"## Manual Closure",
f"- Acceptance: {acceptance}",
"- P1/P2 closure confirmed by signed manual UAT package.",
]
return "\n".join(lines) + "\n"
def build_acceptance_release_note(
*,
session_id: str,
session_dir: Path,
acceptance: str,
open_counts: dict[str, int],
) -> str:
lines = [
"# Release Notes - v1.0.0-mvp Accepted",
"",
f"Date: {utc_now().date().isoformat()}",
"",
"## Acceptance Result",
f"- Session ID: {session_id}",
f"- Acceptance: {acceptance}",
f"- Manual session evidence: `{session_dir.as_posix()}`",
"",
"## Closed Gate 4 Conditions",
"- UAT signed with real operators/supervisors",
"- P1/P2 defects closed",
"",
"## Remaining Deferred Items",
f"- Open P3: {open_counts['P3']}",
f"- Open P4: {open_counts['P4']}",
"- Deferred scope remains tracked in `docs/roadmap/05-wave2-backlog.md`.",
"",
"## Baseline",
"- Accepted baseline remains the current MVP v1 scope without API expansion.",
"- Stage 1-3 public routes remain unchanged.",
]
return "\n".join(lines) + "\n"
def finalize_manual_session(
*,
session_dir: Path,
acceptance: str,
dry_run: bool = False,
) -> dict[str, int]:
required_files = {
"session_protocol": session_dir / "session-protocol.md",
"scenario_checklist": session_dir / "scenario-checklist.md",
"defect_log": session_dir / "defect-log.csv",
"signoff_sheet": session_dir / "signoff-sheet.md",
"manifest": session_dir / "manifest.json",
}
missing = [name for name, path in required_files.items() if not path.exists()]
attachments_dir = session_dir / "attachments"
if not attachments_dir.exists():
missing.append("attachments")
if missing:
raise FileNotFoundError(f"Manual session bundle is incomplete: {missing}")
validation_errors: list[str] = []
validation_errors.extend(validate_session_protocol(required_files["session_protocol"]))
validation_errors.extend(validate_signoff_sheet(required_files["signoff_sheet"]))
open_counts = parse_open_defect_counts(required_files["defect_log"])
if open_counts["P1"] != 0 or open_counts["P2"] != 0:
validation_errors.append(
f"Cannot close MVP pilot with open P1/P2 defects: P1={open_counts['P1']}, P2={open_counts['P2']}"
)
if validation_errors:
details = "\n".join(f"- {error}" for error in validation_errors)
raise ValueError(f"Manual UAT package validation failed:\n{details}")
if dry_run:
return open_counts
session_id = session_dir.name.replace("manual_", "", 1)
gate4_content = GATE4_PATH.read_text(encoding="utf-8")
updated_gate4 = apply_gate4_closure(
gate4_content,
session_id=session_id,
session_dir=session_dir,
acceptance=acceptance,
open_counts=open_counts,
)
GATE4_PATH.write_text(updated_gate4, encoding="utf-8")
P1P2_PATH.write_text(
build_p1p2_register(
session_id=session_id,
session_dir=session_dir,
open_counts=open_counts,
acceptance=acceptance,
),
encoding="utf-8",
)
ACCEPTED_RELEASE_PATH.write_text(
build_acceptance_release_note(
session_id=session_id,
session_dir=session_dir,
acceptance=acceptance,
open_counts=open_counts,
),
encoding="utf-8",
)
return open_counts
def main() -> None:
parser = argparse.ArgumentParser(description="Validate a manual UAT bundle and close MVP Gate 4")
parser.add_argument("--session-dir", required=True, help="Path to the prepared manual session directory")
parser.add_argument(
"--acceptance",
choices=["accepted_for_pilot_completion", "accepted_with_conditions"],
default="accepted_for_pilot_completion",
help="Acceptance mode recorded in gate closure artifacts",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Validate the manual session bundle without updating gate/release documents",
)
args = parser.parse_args()
session_dir = Path(args.session_dir)
if not session_dir.is_absolute():
session_dir = (ROOT / session_dir).resolve()
counts = finalize_manual_session(
session_dir=session_dir,
acceptance=args.acceptance,
dry_run=args.dry_run,
)
prefix = "MVP pilot finalization dry-run passed:" if args.dry_run else "MVP pilot finalized:"
print(f"{prefix} P1={counts['P1']} P2={counts['P2']} P3={counts['P3']} P4={counts['P4']}")
if __name__ == "__main__":
main()
-64
View File
@@ -1,64 +0,0 @@
from __future__ import annotations
import asyncio
import sys
import httpx
REQUIRED_SERVICES = [
"auth",
"audit",
"customer",
"interaction",
"routing",
"voice",
"telegram",
"kb",
"reporting",
"supervisor",
]
async def check_gateway(base_url: str) -> bool:
async with httpx.AsyncClient(base_url=base_url, timeout=5) as client:
try:
health = await client.get("/health")
if health.status_code != 200:
print(f"[FAIL] gateway /health status={health.status_code}")
return False
registry = await client.get("/registry")
if registry.status_code != 200:
print(f"[FAIL] gateway /registry status={registry.status_code}")
return False
data = registry.json()
services = data.get("services", {})
missing = [s for s in REQUIRED_SERVICES if s not in services]
if missing:
print(f"[FAIL] registry missing services: {missing}")
return False
login = await client.post(
"/proxy/auth/auth/login",
json={"username": "admin", "password": "admin123"},
)
if login.status_code != 200:
print(f"[FAIL] auth login status={login.status_code}")
return False
print("[PASS] Stage 1 gateway/auth health checks passed")
return True
except Exception as exc:
print(f"[FAIL] exception: {exc}")
return False
async def main() -> None:
base_url = "http://localhost:8080"
ok = await check_gateway(base_url)
sys.exit(0 if ok else 1)
if __name__ == "__main__":
asyncio.run(main())
-303
View File
@@ -1,303 +0,0 @@
from __future__ import annotations
import argparse
import asyncio
import os
from pathlib import Path
import shutil
import subprocess
import sys
import time
from typing import Any
import httpx
ROOT = Path(__file__).resolve().parents[1]
DATA_ROOT = ROOT / ".data_gate3"
SERVICE_SPECS = [
{"name": "auth", "module": "services.auth_service.app:app", "port": 28001},
{"name": "audit", "module": "services.audit_service.app:app", "port": 28002},
{"name": "customer", "module": "services.customer_service.app:app", "port": 28003},
{"name": "interaction", "module": "services.interaction_service.app:app", "port": 28004},
{"name": "routing", "module": "services.routing_service.app:app", "port": 28005},
{"name": "voice", "module": "services.voice_adapter_service.app:app", "port": 28006},
{"name": "telegram", "module": "services.telegram_adapter_service.app:app", "port": 28007},
{"name": "kb", "module": "services.kb_service.app:app", "port": 28008},
{"name": "reporting", "module": "services.reporting_service.app:app", "port": 28009},
{"name": "supervisor", "module": "services.supervisor_service.app:app", "port": 28010},
{"name": "gateway", "module": "gateway.app:app", "port": 28080},
]
REQUIRED_STAGE3_SERVICES = ["kb", "reporting", "supervisor"]
async def wait_for_health(base_url: str, retries: int = 80, delay: float = 0.25) -> None:
async with httpx.AsyncClient(timeout=2) as client:
for _ in range(retries):
try:
response = await client.get(f"{base_url}/health")
if response.status_code == 200:
return
except Exception:
pass
await asyncio.sleep(delay)
raise RuntimeError(f"Service not healthy: {base_url}/health")
def start_one_service(spec: dict[str, Any], database_url: str | None, data_dir: Path) -> subprocess.Popen:
env = os.environ.copy()
env["CC_DATA_DIR"] = str(data_dir)
if database_url:
env["DATABASE_URL"] = database_url
if spec["name"] == "gateway":
env["AUTH_SERVICE_URL"] = "http://127.0.0.1:28001"
env["AUDIT_SERVICE_URL"] = "http://127.0.0.1:28002"
env["CUSTOMER_SERVICE_URL"] = "http://127.0.0.1:28003"
env["INTERACTION_SERVICE_URL"] = "http://127.0.0.1:28004"
env["ROUTING_SERVICE_URL"] = "http://127.0.0.1:28005"
env["VOICE_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:28006"
env["TELEGRAM_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:28007"
env["KB_SERVICE_URL"] = "http://127.0.0.1:28008"
env["REPORTING_SERVICE_URL"] = "http://127.0.0.1:28009"
env["SUPERVISOR_SERVICE_URL"] = "http://127.0.0.1:28010"
cmd = [
sys.executable,
"-m",
"uvicorn",
spec["module"],
"--host",
"127.0.0.1",
"--port",
str(spec["port"]),
]
return subprocess.Popen(
cmd,
cwd=str(ROOT),
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
async def start_services_sequential(database_url: str | None, data_dir: Path) -> list[subprocess.Popen]:
data_dir.mkdir(parents=True, exist_ok=True)
processes: list[subprocess.Popen] = []
for spec in SERVICE_SPECS:
proc = start_one_service(spec, database_url, data_dir)
processes.append(proc)
await wait_for_health(f"http://127.0.0.1:{spec['port']}", retries=60, delay=0.2)
return processes
def stop_services(processes: list[subprocess.Popen]) -> None:
for proc in processes:
if proc.poll() is not None:
continue
try:
proc.terminate()
except Exception:
pass
for proc in processes:
try:
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
async def run_gate3_checks(base_url: str, reporting_url: str | None = None) -> bool:
contract_path = ROOT / "contracts" / "openapi" / "stage3-kb-reporting-supervisor.yaml"
if not contract_path.exists():
print(f"[FAIL] Missing Stage 3 OpenAPI: {contract_path}")
return False
print(f"[PASS] Stage 3 OpenAPI present: {contract_path.name}")
operator = {"X-User": "operator", "X-Role": "operator"}
analyst = {"X-User": "analyst", "X-Role": "analyst"}
async with httpx.AsyncClient(base_url=base_url, timeout=10) as client:
health = await client.get("/health")
if health.status_code != 200:
print(f"[FAIL] gateway /health status={health.status_code}")
return False
registry = await client.get("/registry")
if registry.status_code != 200:
print(f"[FAIL] gateway /registry status={registry.status_code}")
return False
services = registry.json().get("services", {})
missing = [name for name in REQUIRED_STAGE3_SERVICES if name not in services]
if missing:
print(f"[FAIL] registry missing Stage 3 services: {missing}")
return False
print("[PASS] Gateway and registry checks")
denied = await client.post(
"/proxy/kb/knowledge/categories",
headers=operator,
json={"name": "Forbidden", "description": "Role check"},
)
if denied.status_code != 403:
print(f"[FAIL] KB role check expected 403, got {denied.status_code}")
return False
category = await client.post(
"/proxy/kb/knowledge/categories",
headers=analyst,
json={"name": "Stage 3 KB", "description": "Gate 3"},
)
if category.status_code != 200:
print(f"[FAIL] KB category create status={category.status_code}")
return False
category_id = category.json()["category_id"]
article = await client.post(
"/proxy/kb/knowledge/articles",
headers=analyst,
json={
"category_id": category_id,
"title": "Install helper",
"body": "Step-by-step instruction for operator",
"tags": ["stage3", "kb"],
},
)
if article.status_code != 200:
print(f"[FAIL] KB article create status={article.status_code}")
return False
search = await client.get("/proxy/kb/knowledge/search?q=helper")
if search.status_code != 200 or len(search.json()) < 1:
print(
f"[FAIL] KB search failed status={search.status_code} count={len(search.json()) if search.status_code == 200 else 0}"
)
return False
print("[PASS] KB checks")
rows = [
{
"queue_id": "q_gate3",
"answered": True,
"wait_seconds": 15,
"handle_seconds": 95,
"abandoned": False,
"resolved_first_contact": True,
},
{
"queue_id": "q_gate3",
"answered": False,
"wait_seconds": 20,
"handle_seconds": 0,
"abandoned": True,
"resolved_first_contact": False,
},
]
for row in rows:
ingested = await client.post("/proxy/reporting/reports/events", json=row)
if ingested.status_code != 200:
print(f"[FAIL] reporting ingest status={ingested.status_code}")
return False
kpi = await client.get("/proxy/reporting/reports/kpi?queue_id=q_gate3&sl_threshold_seconds=30")
if kpi.status_code != 200:
print(f"[FAIL] reporting KPI status={kpi.status_code}")
return False
kpi_body = kpi.json()
if "kpi" not in kpi_body or not {"SL", "ASA", "AHT", "Abandon", "FCR"}.issubset(set(kpi_body["kpi"])):
print("[FAIL] KPI payload missing required keys")
return False
print("[PASS] Reporting KPI checks")
report_service_url = reporting_url or services.get("reporting")
if not report_service_url:
print("[FAIL] reporting service URL not found")
return False
async with httpx.AsyncClient(base_url=report_service_url, timeout=10) as reporting_client:
exported = await reporting_client.get("/reports/export")
if exported.status_code != 200:
print(f"[FAIL] reporting export status={exported.status_code}")
return False
if "queue_id,answered,wait_seconds,handle_seconds,abandoned,resolved_first_contact,created_at" not in exported.text:
print("[FAIL] reporting export missing CSV header")
return False
if "q_gate3" not in exported.text:
print("[FAIL] reporting export missing inserted data")
return False
print("[PASS] Reporting CSV export checks")
up1 = await client.post(
"/proxy/supervisor/supervisor/agent-states",
json={"agent_id": "a_gate3_1", "state": "READY", "queue_id": "q_gate3"},
)
up2 = await client.post(
"/proxy/supervisor/supervisor/agent-states",
json={"agent_id": "a_gate3_2", "state": "BUSY", "queue_id": "q_gate3"},
)
queue = await client.post(
"/proxy/supervisor/supervisor/queue-metrics?queue_id=q_gate3&in_queue=3&avg_wait_seconds=21"
)
if up1.status_code != 200 or up2.status_code != 200 or queue.status_code != 200:
print(
f"[FAIL] supervisor updates status: agent1={up1.status_code}, agent2={up2.status_code}, queue={queue.status_code}"
)
return False
realtime = await client.get("/proxy/supervisor/supervisor/realtime")
if realtime.status_code != 200:
print(f"[FAIL] supervisor realtime status={realtime.status_code}")
return False
body: dict[str, Any] = realtime.json()
if body.get("agents", {}).get("total", 0) < 2:
print("[FAIL] supervisor realtime has less than 2 agents")
return False
queues = body.get("queues", [])
if not any(item.get("queue_id") == "q_gate3" for item in queues):
print("[FAIL] supervisor realtime missing q_gate3 queue snapshot")
return False
print("[PASS] Supervisor realtime checks")
print("[PASS] Gate 3 checks completed")
return True
async def main() -> None:
parser = argparse.ArgumentParser(description="Gate 3 checker")
parser.add_argument("--base-url", default="http://localhost:8080", help="Gateway base URL")
parser.add_argument("--auto-start", action="store_true", help="Auto-start local services for the check")
parser.add_argument(
"--database-url",
default=os.getenv("DATABASE_URL", ""),
help="Optional DB URL for auto-start mode",
)
args = parser.parse_args()
if not args.auto_start:
ok = await run_gate3_checks(args.base_url)
sys.exit(0 if ok else 1)
db_url = args.database_url.strip() or None
run_data_dir = DATA_ROOT / f"run_{int(time.time() * 1000)}"
processes: list[subprocess.Popen] = []
try:
processes = await start_services_sequential(db_url, run_data_dir)
ok = await run_gate3_checks("http://127.0.0.1:28080", reporting_url="http://127.0.0.1:28009")
if db_url:
print(f"[INFO] DB mode: {db_url}")
sys.exit(0 if ok else 1)
finally:
stop_services(processes)
try:
shutil.rmtree(run_data_dir)
except Exception:
pass
if __name__ == "__main__":
asyncio.run(main())
-350
View File
@@ -1,350 +0,0 @@
from __future__ import annotations
import argparse
import asyncio
import os
from pathlib import Path
import shutil
import statistics
import subprocess
import sys
import time
from typing import Any
import httpx
ROOT = Path(__file__).resolve().parents[1]
DATA_ROOT = ROOT / ".data_gate4"
SERVICE_SPECS = [
{"name": "auth", "module": "services.auth_service.app:app", "port": 38001},
{"name": "audit", "module": "services.audit_service.app:app", "port": 38002},
{"name": "customer", "module": "services.customer_service.app:app", "port": 38003},
{"name": "interaction", "module": "services.interaction_service.app:app", "port": 38004},
{"name": "routing", "module": "services.routing_service.app:app", "port": 38005},
{"name": "voice", "module": "services.voice_adapter_service.app:app", "port": 38006},
{"name": "telegram", "module": "services.telegram_adapter_service.app:app", "port": 38007},
{"name": "kb", "module": "services.kb_service.app:app", "port": 38008},
{"name": "reporting", "module": "services.reporting_service.app:app", "port": 38009},
{"name": "supervisor", "module": "services.supervisor_service.app:app", "port": 38010},
{"name": "gateway", "module": "gateway.app:app", "port": 38080},
]
async def wait_for_health(base_url: str, retries: int = 60, delay: float = 0.2) -> None:
async with httpx.AsyncClient(timeout=2) as client:
for _ in range(retries):
try:
response = await client.get(f"{base_url}/health")
if response.status_code == 200:
return
except Exception:
pass
await asyncio.sleep(delay)
raise RuntimeError(f"Service not healthy: {base_url}/health")
def start_one_service(spec: dict[str, Any], data_dir: Path) -> subprocess.Popen:
env = os.environ.copy()
env["CC_DATA_DIR"] = str(data_dir)
if spec["name"] == "gateway":
env["AUTH_SERVICE_URL"] = "http://127.0.0.1:38001"
env["AUDIT_SERVICE_URL"] = "http://127.0.0.1:38002"
env["CUSTOMER_SERVICE_URL"] = "http://127.0.0.1:38003"
env["INTERACTION_SERVICE_URL"] = "http://127.0.0.1:38004"
env["ROUTING_SERVICE_URL"] = "http://127.0.0.1:38005"
env["VOICE_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:38006"
env["TELEGRAM_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:38007"
env["KB_SERVICE_URL"] = "http://127.0.0.1:38008"
env["REPORTING_SERVICE_URL"] = "http://127.0.0.1:38009"
env["SUPERVISOR_SERVICE_URL"] = "http://127.0.0.1:38010"
cmd = [
sys.executable,
"-m",
"uvicorn",
spec["module"],
"--host",
"127.0.0.1",
"--port",
str(spec["port"]),
]
return subprocess.Popen(
cmd,
cwd=str(ROOT),
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
async def start_services_sequential(data_dir: Path) -> list[subprocess.Popen]:
data_dir.mkdir(parents=True, exist_ok=True)
processes: list[subprocess.Popen] = []
for spec in SERVICE_SPECS:
proc = start_one_service(spec, data_dir)
processes.append(proc)
await wait_for_health(f"http://127.0.0.1:{spec['port']}")
return processes
def stop_services(processes: list[subprocess.Popen]) -> None:
for proc in processes:
if proc.poll() is not None:
continue
try:
proc.terminate()
except Exception:
pass
for proc in processes:
try:
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
async def _create_interaction(client: httpx.AsyncClient, channel: str, idx: int) -> tuple[bool, float]:
headers = {"X-User": "operator", "X-Role": "operator"}
payload = {
"channel": channel,
"subject": f"Gate4 load {channel} #{idx}",
"customer_id": None,
"queue_id": "q_gate4",
"priority": 3,
}
started = time.perf_counter()
try:
resp = await client.post("/proxy/interaction/interactions", headers=headers, json=payload, timeout=10)
elapsed = time.perf_counter() - started
return resp.status_code == 200, elapsed
except Exception:
elapsed = time.perf_counter() - started
return False, elapsed
async def run_load_wave(base_url: str, voice: int, digital: int) -> dict[str, float | int]:
async with httpx.AsyncClient(base_url=base_url) as client:
tasks = []
for i in range(voice):
tasks.append(_create_interaction(client, "voice", i))
for i in range(digital):
tasks.append(_create_interaction(client, "telegram", i))
results = await asyncio.gather(*tasks)
ok_times = [t for ok, t in results if ok]
failed = len(results) - len(ok_times)
if ok_times:
p95 = sorted(ok_times)[max(0, int(len(ok_times) * 0.95) - 1)]
avg = statistics.mean(ok_times)
else:
p95 = 0.0
avg = 0.0
return {
"total": len(results),
"success": len(ok_times),
"failed": failed,
"avg_seconds": round(avg, 4),
"p95_seconds": round(p95, 4),
}
async def get_interaction_count(base_url: str, limit: int = 1000) -> int:
async with httpx.AsyncClient(base_url=base_url, timeout=10) as client:
response = await client.get(f"/proxy/interaction/interactions?limit={limit}")
response.raise_for_status()
items = response.json()
return len(items)
async def run_security_smoke(base_url: str) -> bool:
operator = {"X-User": "operator", "X-Role": "operator"}
async with httpx.AsyncClient(base_url=base_url, timeout=10) as client:
create_user = await client.post(
"/proxy/auth/users",
headers=operator,
json={
"username": "sec_denied_user",
"password": "secret123",
"full_name": "Security Denied",
"role": "operator",
},
)
if create_user.status_code != 403:
print(f"[FAIL] Security RBAC auth/users expected 403, got {create_user.status_code}")
return False
create_queue = await client.post(
"/proxy/routing/queues",
json={
"name": "Denied queue",
"description": "Role check",
"rules": [{"channel": "voice", "priority": 3, "strategy": "round_robin", "sla_seconds": 30}],
},
)
if create_queue.status_code != 403:
print(f"[FAIL] Security RBAC routing/queues expected 403, got {create_queue.status_code}")
return False
kb_create = await client.post(
"/proxy/kb/knowledge/categories",
headers=operator,
json={"name": "Denied KB", "description": "Role check"},
)
if kb_create.status_code != 403:
print(f"[FAIL] Security RBAC kb category expected 403, got {kb_create.status_code}")
return False
print("[PASS] Security smoke checks")
return True
def run_backup_restore(source_dir: Path) -> tuple[Path, Path]:
backup_script = ROOT / "scripts" / "backup_data.ps1"
restore_script = ROOT / "scripts" / "restore_data.ps1"
backup_output = DATA_ROOT / f"backups_{int(time.time() * 1000)}"
backup_output.mkdir(parents=True, exist_ok=True)
backup_cmd = [
"powershell",
"-ExecutionPolicy",
"Bypass",
"-File",
str(backup_script),
"-SourceDir",
str(source_dir),
"-OutputDir",
str(backup_output),
]
result = subprocess.run(backup_cmd, cwd=str(ROOT), capture_output=True, text=True, check=False)
if result.returncode != 0:
raise RuntimeError(f"Backup failed: {result.stdout}\n{result.stderr}")
archives = sorted(backup_output.glob("*.zip"), key=lambda p: p.stat().st_mtime, reverse=True)
if not archives:
raise RuntimeError("Backup archive not created")
archive = archives[0]
if source_dir.exists():
shutil.rmtree(source_dir)
restore_cmd = [
"powershell",
"-ExecutionPolicy",
"Bypass",
"-File",
str(restore_script),
"-BackupZip",
str(archive),
"-TargetDir",
str(source_dir),
]
restore = subprocess.run(restore_cmd, cwd=str(ROOT), capture_output=True, text=True, check=False)
if restore.returncode != 0:
raise RuntimeError(f"Restore failed: {restore.stdout}\n{restore.stderr}")
db_file = source_dir / "mvp_cc.db"
if not db_file.exists():
raise RuntimeError(f"Restored DB file not found: {db_file}")
return backup_output, archive
def check_docs_bundle() -> bool:
required = [
ROOT / "docs" / "security" / "checklist.md",
ROOT / "docs" / "releases" / "v1.0.0-mvp.md",
ROOT / "docs" / "gates" / "mvp-pilot-baseline.md",
ROOT / "docs" / "gates" / "p1-p2-defects.md",
ROOT / "docs" / "roadmap" / "05-wave2-backlog.md",
ROOT / "docs" / "runbooks" / "backup-restore.md",
ROOT / "docs" / "runbooks" / "load-test-plan.md",
ROOT / "docs" / "runbooks" / "pilot-uat.md",
ROOT / "docs" / "uat" / "README.md",
ROOT / "docs" / "uat" / "scenario-checklist.md",
ROOT / "docs" / "uat" / "session-template.md",
ROOT / "docs" / "uat" / "signoff-template.md",
ROOT / "docs" / "uat" / "defect-log-template.csv",
]
missing = [str(path) for path in required if not path.exists()]
if missing:
print(f"[FAIL] Missing required Stage 4 docs: {missing}")
return False
print("[PASS] Stage 4 docs bundle present")
return True
async def main() -> None:
parser = argparse.ArgumentParser(description="Gate 4 checker: load, backup/restore, security smoke")
parser.add_argument("--voice", type=int, default=100, help="Concurrent voice requests")
parser.add_argument("--digital", type=int, default=100, help="Concurrent digital requests")
parser.add_argument("--keep-artifacts", action="store_true", help="Keep .data_gate4 run and backup artifacts")
args = parser.parse_args()
run_data_dir = DATA_ROOT / f"run_{int(time.time() * 1000)}"
backup_output: Path | None = None
processes: list[subprocess.Popen] = []
try:
processes = await start_services_sequential(run_data_dir)
base_url = "http://127.0.0.1:38080"
load_result = await run_load_wave(base_url, args.voice, args.digital)
print("[INFO] Load result:", load_result)
if load_result["failed"] != 0 or load_result["success"] != load_result["total"]:
print("[FAIL] Load target not reached with zero errors")
sys.exit(1)
print("[PASS] Load target reached")
count_before = await get_interaction_count(base_url, limit=max(500, args.voice + args.digital + 100))
if count_before < (args.voice + args.digital):
print(
f"[FAIL] Interaction count before backup too low: {count_before} < {args.voice + args.digital}"
)
sys.exit(1)
print(f"[PASS] Interaction count before backup: {count_before}")
security_ok = await run_security_smoke(base_url)
if not security_ok:
sys.exit(1)
stop_services(processes)
processes = []
backup_output, archive = run_backup_restore(run_data_dir)
print(f"[PASS] Backup/restore scripts completed, archive: {archive}")
processes = await start_services_sequential(run_data_dir)
count_after = await get_interaction_count(base_url, limit=max(500, args.voice + args.digital + 100))
if count_after != count_before:
print(f"[FAIL] Restored interaction count mismatch: before={count_before}, after={count_after}")
sys.exit(1)
print(f"[PASS] Restore data integrity validated: {count_after} interactions")
if not check_docs_bundle():
sys.exit(1)
print("[PASS] Gate 4 automated checks completed")
finally:
stop_services(processes)
if not args.keep_artifacts:
try:
if run_data_dir.exists():
shutil.rmtree(run_data_dir)
except Exception:
pass
try:
if backup_output and backup_output.exists():
shutil.rmtree(backup_output)
except Exception:
pass
if __name__ == "__main__":
asyncio.run(main())
-251
View File
@@ -1,251 +0,0 @@
from __future__ import annotations
import argparse
import asyncio
import os
from pathlib import Path
import shutil
import subprocess
import sys
import time
from typing import Any
import httpx
ROOT = Path(__file__).resolve().parents[1]
DATA_ROOT = ROOT / ".data_smoke"
SERVICE_SPECS = [
{"name": "auth", "module": "services.auth_service.app:app", "port": 18001},
{"name": "audit", "module": "services.audit_service.app:app", "port": 18002},
{"name": "customer", "module": "services.customer_service.app:app", "port": 18003},
{"name": "interaction", "module": "services.interaction_service.app:app", "port": 18004},
{"name": "routing", "module": "services.routing_service.app:app", "port": 18005},
{"name": "voice", "module": "services.voice_adapter_service.app:app", "port": 18006},
{"name": "telegram", "module": "services.telegram_adapter_service.app:app", "port": 18007},
{"name": "kb", "module": "services.kb_service.app:app", "port": 18008},
{"name": "reporting", "module": "services.reporting_service.app:app", "port": 18009},
{"name": "supervisor", "module": "services.supervisor_service.app:app", "port": 18010},
{"name": "gateway", "module": "gateway.app:app", "port": 18080},
]
async def wait_for_health(base_url: str, retries: int = 80, delay: float = 0.25) -> None:
async with httpx.AsyncClient(timeout=2) as client:
for _ in range(retries):
try:
response = await client.get(f"{base_url}/health")
if response.status_code == 200:
return
except Exception:
pass
await asyncio.sleep(delay)
raise RuntimeError(f"Service not healthy: {base_url}/health")
def start_services(database_url: str | None, data_dir: Path) -> list[subprocess.Popen]:
data_dir.mkdir(parents=True, exist_ok=True)
processes: list[subprocess.Popen] = []
for spec in SERVICE_SPECS:
env = os.environ.copy()
env["CC_DATA_DIR"] = str(data_dir)
if database_url:
env["DATABASE_URL"] = database_url
if spec["name"] == "gateway":
env["AUTH_SERVICE_URL"] = "http://127.0.0.1:18001"
env["AUDIT_SERVICE_URL"] = "http://127.0.0.1:18002"
env["CUSTOMER_SERVICE_URL"] = "http://127.0.0.1:18003"
env["INTERACTION_SERVICE_URL"] = "http://127.0.0.1:18004"
env["ROUTING_SERVICE_URL"] = "http://127.0.0.1:18005"
env["VOICE_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:18006"
env["TELEGRAM_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:18007"
env["KB_SERVICE_URL"] = "http://127.0.0.1:18008"
env["REPORTING_SERVICE_URL"] = "http://127.0.0.1:18009"
env["SUPERVISOR_SERVICE_URL"] = "http://127.0.0.1:18010"
cmd = [
sys.executable,
"-m",
"uvicorn",
spec["module"],
"--host",
"127.0.0.1",
"--port",
str(spec["port"]),
]
proc = subprocess.Popen(
cmd,
cwd=str(ROOT),
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
processes.append(proc)
return processes
def stop_services(processes: list[subprocess.Popen]) -> None:
for proc in processes:
if proc.poll() is not None:
continue
try:
proc.terminate()
except Exception:
pass
for proc in processes:
try:
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
async def run_gate_checks() -> dict[str, Any]:
for spec in SERVICE_SPECS:
await wait_for_health(f"http://127.0.0.1:{spec['port']}")
base = "http://127.0.0.1:18080"
admin = {"X-User": "admin", "X-Role": "admin"}
operator = {"X-User": "operator", "X-Role": "operator"}
supervisor = {"X-User": "supervisor", "X-Role": "supervisor"}
async with httpx.AsyncClient(base_url=base, timeout=10) as client:
health = await client.get("/health")
health.raise_for_status()
login = await client.post("/proxy/auth/auth/login", json={"username": "admin", "password": "admin123"})
login.raise_for_status()
queue = await client.post(
"/proxy/routing/queues",
headers=admin,
json={
"name": "Main Queue",
"description": "Smoke queue",
"rules": [
{"channel": "voice", "priority": 3, "strategy": "round_robin", "sla_seconds": 30},
{"channel": "telegram", "priority": 3, "strategy": "round_robin", "sla_seconds": 45},
],
},
)
queue.raise_for_status()
queue_id = queue.json()["queue_id"]
customer = await client.post(
"/proxy/customer/customers",
json={
"display_name": "Gate User",
"phones": ["+77010000011"],
"preferred_phone": "+77010000011",
"tags": ["smoke"],
},
)
customer.raise_for_status()
customer_id = customer.json()["customer_id"]
interaction = await client.post(
"/proxy/interaction/interactions",
headers=operator,
json={
"channel": "voice",
"subject": "Gate voice check",
"customer_id": customer_id,
"queue_id": queue_id,
"priority": 3,
},
)
interaction.raise_for_status()
interaction_id = interaction.json()["interaction_id"]
assign = await client.patch(
f"/proxy/interaction/interactions/{interaction_id}/assign",
headers=supervisor,
json={"assignee": "operator_a"},
)
assign.raise_for_status()
escalate = await client.post(
f"/proxy/interaction/interactions/{interaction_id}/escalate",
headers=operator,
json={"target_queue_id": "line2"},
)
escalate.raise_for_status()
close = await client.patch(
f"/proxy/interaction/interactions/{interaction_id}/status",
headers=operator,
json={"status": "closed"},
)
close.raise_for_status()
voice_event = await client.post(
"/proxy/voice/integrations/voice/events",
headers=operator,
json={
"event_type": "call.started",
"call_id": "smoke_call_1",
"interaction_id": interaction_id,
"payload": {"source": "live_smoke"},
},
)
voice_event.raise_for_status()
tg_event = await client.post(
"/proxy/telegram/integrations/telegram/webhook",
json={
"chat_id": "smoke_chat",
"text": "Smoke message",
"customer_external_id": None,
"payload": {"source": "live_smoke"},
},
)
tg_event.raise_for_status()
interactions = await client.get("/proxy/interaction/interactions")
interactions.raise_for_status()
rows = interactions.json()
return {
"gateway": health.json(),
"queue_id": queue_id,
"customer_id": customer_id,
"interaction_id": interaction_id,
"interactions_total": len(rows),
}
async def main() -> None:
parser = argparse.ArgumentParser(description="Live Gate 1/2 smoke")
parser.add_argument(
"--database-url",
default=os.getenv("DATABASE_URL", ""),
help="Optional DB URL override for started services",
)
args = parser.parse_args()
db_url = args.database_url.strip() or None
run_data_dir = DATA_ROOT / f"run_{int(time.time() * 1000)}"
processes = start_services(db_url, run_data_dir)
try:
result = await run_gate_checks()
print("Gate 1/2 live smoke passed")
print(result)
if db_url:
print(f"DB mode: {db_url}")
finally:
stop_services(processes)
try:
shutil.rmtree(run_data_dir)
except Exception:
pass
if __name__ == "__main__":
asyncio.run(main())
-295
View File
@@ -1,295 +0,0 @@
from __future__ import annotations
import argparse
import json
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from statistics import median
from sqlalchemy import create_engine, text
def _parse_iso(value: str | None) -> datetime | None:
raw = str(value or "").strip()
if not raw:
return None
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _percentile(values: list[float], q: float) -> float | None:
if not values:
return None
if len(values) == 1:
return values[0]
sorted_values = sorted(values)
idx = (len(sorted_values) - 1) * q
lo = int(idx)
hi = min(lo + 1, len(sorted_values) - 1)
if lo == hi:
return sorted_values[lo]
frac = idx - lo
return sorted_values[lo] + (sorted_values[hi] - sorted_values[lo]) * frac
@dataclass
class CallTimeline:
call_id: str
started_at: datetime | None = None
ended_at: datetime | None = None
ended_reconciled: bool | None = None
recording_ready_at: datetime | None = None
recording_ready_reconciled: bool | None = None
recording_uploaded_at: datetime | None = None
interaction_id: str | None = None
def start_to_end_seconds(self) -> float | None:
if self.started_at and self.ended_at:
return max((self.ended_at - self.started_at).total_seconds(), 0.0)
return None
def end_to_recording_ready_seconds(self) -> float | None:
if self.ended_at and self.recording_ready_at:
return max((self.recording_ready_at - self.ended_at).total_seconds(), 0.0)
return None
def end_to_recording_upload_seconds(self) -> float | None:
if self.ended_at and self.recording_uploaded_at:
return max((self.recording_uploaded_at - self.ended_at).total_seconds(), 0.0)
return None
def status(self) -> str:
if self.started_at and not self.ended_at:
return "active_no_end"
if self.ended_at and not self.recording_uploaded_at:
return "ended_no_recording_upload"
if self.ended_at and self.recording_uploaded_at:
return "complete"
return "unknown"
def main() -> int:
parser = argparse.ArgumentParser(
description="Track 10: report Asterisk voice lifecycle latency."
)
parser.add_argument("--database-url", required=True)
parser.add_argument("--since-hours", type=int, default=6)
parser.add_argument("--since-minutes", type=int, default=0)
parser.add_argument("--limit-events", type=int, default=5000)
parser.add_argument("--max-started-to-ended-seconds", type=float, default=30.0)
parser.add_argument("--max-ended-to-recording-seconds", type=float, default=45.0)
parser.add_argument("--print-limit", type=int, default=15)
parser.add_argument("--json-out", default="")
parser.add_argument("--breach-mode", choices=["all", "direct"], default="direct")
parser.add_argument("--fail-on-breach", action="store_true")
args = parser.parse_args()
if args.since_minutes and args.since_minutes > 0:
cutoff = datetime.now(timezone.utc) - timedelta(minutes=args.since_minutes)
else:
cutoff = datetime.now(timezone.utc) - timedelta(hours=max(args.since_hours, 1))
cutoff_iso = cutoff.isoformat()
engine = create_engine(args.database_url, future=True)
calls: dict[str, CallTimeline] = {}
started_from_asterisk: set[str] = set()
with engine.begin() as conn:
rows = conn.execute(
text(
"""
SELECT call_id, interaction_id, event_type, payload_json, created_at
FROM voice_events
WHERE event_type IN ('call.started', 'call.ended', 'recording.ready')
AND created_at >= :cutoff
ORDER BY id DESC
LIMIT :limit
"""
),
{"cutoff": cutoff_iso, "limit": max(args.limit_events, 100)},
).mappings().all()
for row in rows:
call_id = str(row["call_id"] or "").strip()
if not call_id:
continue
payload_raw = str(row["payload_json"] or "{}")
try:
payload = json.loads(payload_raw)
except json.JSONDecodeError:
payload = {}
event_type = str(row["event_type"] or "")
created_at = _parse_iso(str(row["created_at"] or ""))
if created_at is None:
continue
timeline = calls.get(call_id) or CallTimeline(call_id=call_id)
if event_type == "call.started":
if payload.get("source") != "asterisk":
continue
started_from_asterisk.add(call_id)
timeline.started_at = max(filter(None, [timeline.started_at, created_at]))
elif event_type == "call.ended":
if timeline.ended_at is None or created_at > timeline.ended_at:
timeline.ended_at = created_at
timeline.ended_reconciled = bool(payload.get("reconciled"))
elif event_type == "recording.ready":
if timeline.recording_ready_at is None or created_at > timeline.recording_ready_at:
timeline.recording_ready_at = created_at
timeline.recording_ready_reconciled = bool(payload.get("reconciled"))
timeline.interaction_id = timeline.interaction_id or str(row["interaction_id"] or "").strip() or None
calls[call_id] = timeline
rec_rows = conn.execute(
text(
"""
SELECT call_id, interaction_id, created_at
FROM call_recordings
WHERE created_at >= :cutoff
ORDER BY id DESC
LIMIT :limit
"""
),
{"cutoff": cutoff_iso, "limit": max(args.limit_events, 100)},
).mappings().all()
for row in rec_rows:
call_id = str(row["call_id"] or "").strip()
if call_id not in calls:
continue
created_at = _parse_iso(str(row["created_at"] or ""))
if created_at is None:
continue
timeline = calls[call_id]
if timeline.recording_uploaded_at is None or created_at > timeline.recording_uploaded_at:
timeline.recording_uploaded_at = created_at
timeline.interaction_id = timeline.interaction_id or str(row["interaction_id"] or "").strip() or None
items = [calls[call_id] for call_id in started_from_asterisk if call_id in calls]
items.sort(key=lambda x: x.started_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
start_end = [v for v in (item.start_to_end_seconds() for item in items) if v is not None]
end_rec = [v for v in (item.end_to_recording_upload_seconds() for item in items) if v is not None]
direct_items = [item for item in items if item.ended_at is not None and item.ended_reconciled is False]
direct_start_end = [v for v in (item.start_to_end_seconds() for item in direct_items) if v is not None]
direct_end_rec = [
v for v in (item.end_to_recording_upload_seconds() for item in direct_items) if v is not None
]
ended_no_recording = [item for item in items if item.status() == "ended_no_recording_upload"]
active_no_end = [item for item in items if item.status() == "active_no_end"]
p95_start_end = _percentile(start_end, 0.95)
p95_end_rec = _percentile(end_rec, 0.95)
p95_direct_start_end = _percentile(direct_start_end, 0.95)
p95_direct_end_rec = _percentile(direct_end_rec, 0.95)
breach = False
if args.breach_mode == "direct":
left = p95_direct_start_end if p95_direct_start_end is not None else p95_start_end
right = p95_direct_end_rec if p95_direct_end_rec is not None else p95_end_rec
else:
left = p95_start_end
right = p95_end_rec
if left is not None and left > args.max_started_to_ended_seconds:
breach = True
if right is not None and right > args.max_ended_to_recording_seconds:
breach = True
if ended_no_recording:
breach = True
report = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"since_hours": args.since_hours,
"since_minutes": args.since_minutes,
"thresholds": {
"max_started_to_ended_seconds": args.max_started_to_ended_seconds,
"max_ended_to_recording_seconds": args.max_ended_to_recording_seconds,
},
"summary": {
"total_calls": len(items),
"complete_calls": sum(1 for item in items if item.status() == "complete"),
"active_no_end": len(active_no_end),
"ended_no_recording_upload": len(ended_no_recording),
"ended_reconciled_calls": sum(1 for item in items if item.ended_reconciled is True),
"ended_direct_calls": sum(1 for item in items if item.ended_reconciled is False),
"start_to_end": {
"count": len(start_end),
"median": median(start_end) if start_end else None,
"p95": p95_start_end,
},
"end_to_recording_upload": {
"count": len(end_rec),
"median": median(end_rec) if end_rec else None,
"p95": p95_end_rec,
},
"direct_start_to_end": {
"count": len(direct_start_end),
"median": median(direct_start_end) if direct_start_end else None,
"p95": p95_direct_start_end,
},
"direct_end_to_recording_upload": {
"count": len(direct_end_rec),
"median": median(direct_end_rec) if direct_end_rec else None,
"p95": p95_direct_end_rec,
},
"breach_mode": args.breach_mode,
},
"worst_calls": [
{
"call_id": item.call_id,
"interaction_id": item.interaction_id,
"status": item.status(),
"started_at": item.started_at.isoformat() if item.started_at else None,
"ended_at": item.ended_at.isoformat() if item.ended_at else None,
"ended_reconciled": item.ended_reconciled,
"recording_ready_at": item.recording_ready_at.isoformat() if item.recording_ready_at else None,
"recording_ready_reconciled": item.recording_ready_reconciled,
"recording_uploaded_at": item.recording_uploaded_at.isoformat() if item.recording_uploaded_at else None,
"start_to_end_seconds": item.start_to_end_seconds(),
"end_to_recording_upload_seconds": item.end_to_recording_upload_seconds(),
}
for item in items[: max(args.print_limit, 1)]
],
}
print(
"[INFO] Track10 voice latency report: "
f"calls={report['summary']['total_calls']} "
f"complete={report['summary']['complete_calls']} "
f"active_no_end={report['summary']['active_no_end']} "
f"ended_no_recording_upload={report['summary']['ended_no_recording_upload']}"
)
print(
"[INFO] p95 started->ended="
f"{report['summary']['start_to_end']['p95']}s; "
"p95 ended->recording_upload="
f"{report['summary']['end_to_recording_upload']['p95']}s"
)
print(
"[INFO] direct p95 started->ended="
f"{report['summary']['direct_start_to_end']['p95']}s; "
"direct p95 ended->recording_upload="
f"{report['summary']['direct_end_to_recording_upload']['p95']}s"
)
print(json.dumps(report, ensure_ascii=False, indent=2))
if args.json_out.strip():
out_path = Path(args.json_out).expanduser().resolve()
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"[INFO] report written: {out_path}")
if args.fail_on_breach and breach:
print("[FAIL] SLO breach detected for Track 10 criteria")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
-163
View File
@@ -1,163 +0,0 @@
from __future__ import annotations
import json
import sqlite3
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.error import URLError
from urllib.request import Request, urlopen
ROOT = Path(__file__).resolve().parents[1]
DB_PATH = ROOT / ".data_local" / "mvp_cc.db"
OUT_DIR = ROOT / ".artifacts" / "track12-monitor"
OUT_DIR.mkdir(parents=True, exist_ok=True)
OUT_PATH = OUT_DIR / "latest.log"
BASE_URL = "http://127.0.0.1:8080"
AUTH_BASE_URL = "http://127.0.0.1:8001"
USERNAME = "operator"
PASSWORD = "op12345"
ROLE = "operator"
WINDOW_SECONDS = 180
POLL_SECONDS = 1.0
def iso_now() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def write_line(handle, label: str, payload) -> None:
handle.write(f"[{iso_now()}] {label} {json.dumps(payload, ensure_ascii=False, sort_keys=True)}\n")
handle.flush()
def login_headers() -> dict[str, str]:
body = json.dumps(
{
"username": USERNAME,
"password": PASSWORD,
"role": ROLE,
}
).encode("utf-8")
req = Request(
f"{AUTH_BASE_URL}/auth/login",
headers={"Content-Type": "application/json"},
data=body,
method="POST",
)
with urlopen(req, timeout=10) as response:
payload = json.loads(response.read().decode("utf-8", errors="replace"))
token = str(payload.get("access_token") or "").strip()
if not token:
raise RuntimeError("auth/login did not return access_token")
return {"Authorization": f"Bearer {token}"}
def fetch_json(path: str, headers: dict[str, str]):
req = Request(
f"{BASE_URL}{path}",
headers=headers,
method="GET",
)
with urlopen(req, timeout=10) as response:
body = response.read().decode("utf-8", errors="replace")
return json.loads(body)
def latest_events(conn: sqlite3.Connection, limit: int = 6) -> list[dict]:
rows = conn.execute(
"""
select event_id, event_type, call_id, interaction_id, payload_json, created_at
from voice_events
order by id desc
limit ?
""",
(limit,),
).fetchall()
result = []
for row in rows:
payload = row["payload_json"]
try:
payload = json.loads(payload)
except Exception:
pass
result.append(
{
"event_id": row["event_id"],
"event_type": row["event_type"],
"call_id": row["call_id"],
"interaction_id": row["interaction_id"],
"payload": payload,
"created_at": row["created_at"],
}
)
return result
def latest_links(conn: sqlite3.Connection, limit: int = 4) -> list[dict]:
rows = conn.execute(
"""
select call_id, interaction_id, status, telephony_status, claimed_by_user,
operator_extension, started_at, connected_at, ended_at
from asterisk_call_links
order by id desc
limit ?
""",
(limit,),
).fetchall()
return [dict(row) for row in rows]
def main() -> int:
with OUT_PATH.open("w", encoding="utf-8") as handle:
write_line(handle, "monitor", {"status": "started", "window_seconds": WINDOW_SECONDS})
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
headers = login_headers()
write_line(handle, "auth", {"status": "ok", "user": USERNAME, "role": ROLE})
end_at = time.time() + WINDOW_SECONDS
seen = {
"live": None,
"recent": None,
"events": None,
"links": None,
}
try:
while time.time() < end_at:
try:
live = fetch_json("/proxy/asterisk-bridge/asterisk/live-calls", headers)
if live != seen["live"]:
seen["live"] = live
write_line(handle, "live-calls", live)
except URLError as exc:
write_line(handle, "live-calls-error", {"error": str(exc)})
try:
recent = fetch_json("/proxy/asterisk-bridge/asterisk/recent-calls", headers)
if recent != seen["recent"]:
seen["recent"] = recent
write_line(handle, "recent-calls", recent)
except URLError as exc:
write_line(handle, "recent-calls-error", {"error": str(exc)})
events = latest_events(conn)
if events != seen["events"]:
seen["events"] = events
write_line(handle, "voice-events", events)
links = latest_links(conn)
if links != seen["links"]:
seen["links"] = links
write_line(handle, "call-links", links)
time.sleep(POLL_SECONDS)
finally:
conn.close()
write_line(handle, "monitor", {"status": "finished"})
return 0
if __name__ == "__main__":
raise SystemExit(main())
-154
View File
@@ -1,154 +0,0 @@
from __future__ import annotations
import argparse
import json
import subprocess
from pathlib import Path
from typing import Any
def load_summary(report_dir: Path) -> dict[str, Any]:
summary_path = report_dir / "summary.json"
if not summary_path.exists():
raise FileNotFoundError(f"Missing summary.json: {summary_path}")
return json.loads(summary_path.read_text(encoding="utf-8"))
def evaluate_summary(
summary: dict[str, Any],
*,
require_success_rate: float,
require_p95_seconds: float,
require_p99_seconds: float,
) -> list[str]:
issues: list[str] = []
results = summary.get("results", {})
success_rate = float(results.get("success_rate", 0.0))
p95 = float(results.get("p95_seconds", 0.0))
p99 = float(results.get("p99_seconds", 0.0))
five_xx_rate = float(results.get("five_xx_rate", 0.0))
if success_rate < require_success_rate:
issues.append(f"Success rate {success_rate:.2f}% is below threshold {require_success_rate:.2f}%")
if p95 > require_p95_seconds:
issues.append(f"P95 {p95:.2f}s exceeds threshold {require_p95_seconds:.2f}s")
if p99 > require_p99_seconds:
issues.append(f"P99 {p99:.2f}s exceeds threshold {require_p99_seconds:.2f}s")
if five_xx_rate > 0.5:
issues.append(f"5xx/transport error rate {five_xx_rate:.2f}% exceeds 0.50%")
return issues
def _run_kubectl(args: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(args, capture_output=True, text=True, check=False)
def check_pod_health(namespace: str, kubectl_context: str | None = None) -> list[str]:
cmd = ["kubectl"]
if kubectl_context:
cmd.extend(["--context", kubectl_context])
cmd.extend(["get", "pods", "-n", namespace, "-o", "json"])
result = _run_kubectl(cmd)
if result.returncode != 0:
return [f"kubectl get pods failed: {result.stderr.strip() or result.stdout.strip()}"]
payload = json.loads(result.stdout)
issues: list[str] = []
for item in payload.get("items", []):
name = item.get("metadata", {}).get("name", "<unknown>")
status = item.get("status", {})
phase = status.get("phase")
if phase not in {"Running", "Succeeded"}:
issues.append(f"Pod {name} is in unexpected phase {phase}")
for container in status.get("containerStatuses", []):
if container.get("restartCount", 0) > 0:
issues.append(f"Pod {name} restartCount={container.get('restartCount')}")
state = container.get("state", {})
waiting = state.get("waiting") or {}
terminated = state.get("terminated") or {}
if waiting.get("reason") == "CrashLoopBackOff":
issues.append(f"Pod {name} is in CrashLoopBackOff")
if terminated.get("reason") == "OOMKilled":
issues.append(f"Pod {name} had OOMKilled termination")
return issues
def check_kubectl_top(namespace: str, kubectl_context: str | None = None) -> list[str]:
cmd = ["kubectl"]
if kubectl_context:
cmd.extend(["--context", kubectl_context])
cmd.extend(["top", "pods", "-n", namespace, "--no-headers"])
result = _run_kubectl(cmd)
if result.returncode != 0:
return [f"kubectl top pods failed: {result.stderr.strip() or result.stdout.strip()}"]
if not result.stdout.strip():
return ["kubectl top pods returned no rows"]
return []
def check_hpa(namespace: str, kubectl_context: str | None = None) -> list[str]:
cmd = ["kubectl"]
if kubectl_context:
cmd.extend(["--context", kubectl_context])
cmd.extend(["get", "hpa", "-n", namespace, "-o", "json"])
result = _run_kubectl(cmd)
if result.returncode != 0:
return [f"kubectl get hpa failed: {result.stderr.strip() or result.stdout.strip()}"]
payload = json.loads(result.stdout)
items = payload.get("items", [])
if not items:
return ["No HPA objects found in namespace"]
issues: list[str] = []
for item in items:
name = item.get("metadata", {}).get("name", "<unknown>")
status = item.get("status", {})
current_metrics = status.get("currentMetrics") or []
if not current_metrics and status.get("currentCPUUtilizationPercentage") is None:
issues.append(f"HPA {name} has no current utilization metrics")
return issues
def main() -> None:
parser = argparse.ArgumentParser(description="Validate Track 7 scale/hardening evidence.")
parser.add_argument("--namespace", required=True, help="Kubernetes namespace")
parser.add_argument("--report-dir", required=True, help="Directory produced by scripts/load_test.py")
parser.add_argument("--require-success-rate", type=float, default=99.0)
parser.add_argument("--require-p95-seconds", type=float, default=2.0)
parser.add_argument("--require-p99-seconds", type=float, default=3.5)
parser.add_argument("--kubectl-context", default=None)
args = parser.parse_args()
report_dir = Path(args.report_dir)
issues: list[str] = []
try:
summary = load_summary(report_dir)
except Exception as exc:
raise SystemExit(f"[FAIL] {exc}") from exc
issues.extend(
evaluate_summary(
summary,
require_success_rate=args.require_success_rate,
require_p95_seconds=args.require_p95_seconds,
require_p99_seconds=args.require_p99_seconds,
)
)
issues.extend(check_pod_health(args.namespace, kubectl_context=args.kubectl_context))
issues.extend(check_kubectl_top(args.namespace, kubectl_context=args.kubectl_context))
issues.extend(check_hpa(args.namespace, kubectl_context=args.kubectl_context))
if issues:
print("[FAIL] Track 7 validation failed:")
for issue in issues:
print(f"- {issue}")
raise SystemExit(1)
print("[PASS] Track 7 validation checks passed")
print(f"- namespace: {args.namespace}")
print(f"- report_dir: {report_dir}")
if __name__ == "__main__":
main()
-220
View File
@@ -1,220 +0,0 @@
from __future__ import annotations
import argparse
import asyncio
import json
import sys
import time
from pathlib import Path
from typing import Any
if __package__ in {None, ""}:
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from scripts import load_test, track7_check
else:
from scripts import load_test, track7_check
PROFILE_THRESHOLDS = {
"step_250_250": {
"require_success_rate": 99.0,
"require_p95_seconds": 1.5,
"require_p99_seconds": 3.0,
},
"target_500_500": {
"require_success_rate": 99.0,
"require_p95_seconds": 2.0,
"require_p99_seconds": 3.5,
},
}
def build_stage_root(report_root: str | None = None) -> Path:
if report_root:
target = Path(report_root)
else:
timestamp = time.strftime("%Y%m%d_%H%M%S")
target = Path(".artifacts") / "track7" / f"staged_{timestamp}"
target.mkdir(parents=True, exist_ok=True)
return target
def resolve_profiles(profiles_raw: str) -> list[str]:
requested = [item.strip() for item in profiles_raw.split(",") if item.strip()]
if not requested:
raise ValueError("At least one profile is required")
unsupported = [name for name in requested if name not in PROFILE_THRESHOLDS]
if unsupported:
raise ValueError(f"Unsupported staged profiles: {', '.join(unsupported)}")
return requested
def evaluate_stage(
*,
namespace: str,
report_dir: Path,
kubectl_context: str | None,
thresholds: dict[str, float],
) -> list[str]:
summary = track7_check.load_summary(report_dir)
issues: list[str] = []
issues.extend(
track7_check.evaluate_summary(
summary,
require_success_rate=thresholds["require_success_rate"],
require_p95_seconds=thresholds["require_p95_seconds"],
require_p99_seconds=thresholds["require_p99_seconds"],
)
)
issues.extend(track7_check.check_pod_health(namespace, kubectl_context=kubectl_context))
issues.extend(track7_check.check_kubectl_top(namespace, kubectl_context=kubectl_context))
issues.extend(track7_check.check_hpa(namespace, kubectl_context=kubectl_context))
return issues
def write_acceptance_pack(stage_root: Path, payload: dict[str, Any]) -> None:
(stage_root / "acceptance_summary.json").write_text(json.dumps(payload, indent=2), encoding="utf-8")
lines = [
"# Track 7 Staged Validation",
"",
f"- Namespace: `{payload['namespace']}`",
f"- Base URL: `{payload['base_url']}`",
f"- Auth mode: `{payload['auth_mode']}`",
f"- Include read traffic: `{int(payload['include_read_traffic'])}`",
f"- Started at: `{payload['started_at']}`",
f"- Completed at: `{payload['completed_at']}`",
f"- Passed: `{payload['passed']}`",
"",
"## Stages",
"",
]
for stage in payload["stages"]:
lines.extend(
[
f"### {stage['profile']}",
"",
f"- Report dir: `{stage['report_dir']}`",
f"- Passed: `{stage['passed']}`",
f"- Success rate: `{stage['results']['success_rate']:.2f}%`",
f"- P95: `{stage['results']['p95_seconds']:.4f}s`",
f"- P99: `{stage['results']['p99_seconds']:.4f}s`",
]
)
if stage["issues"]:
lines.append("- Issues:")
for issue in stage["issues"]:
lines.append(f" - {issue}")
lines.append("")
(stage_root / "acceptance_summary.md").write_text("\n".join(lines), encoding="utf-8")
async def run_staged_validation(
*,
base_url: str,
namespace: str,
profiles: list[str],
auth_mode: str,
include_read_traffic: bool,
kubectl_context: str | None,
report_root: str | None,
) -> tuple[dict[str, Any], Path]:
stage_root = build_stage_root(report_root)
started_at = time.strftime("%Y-%m-%dT%H:%M:%S")
stages: list[dict[str, Any]] = []
for profile_name in profiles:
thresholds = PROFILE_THRESHOLDS[profile_name]
report_dir = stage_root / profile_name
summary, out_dir = await load_test.run_profile(
base_url=base_url,
profile_name=profile_name,
ramp_seconds=None,
hold_seconds=None,
voice=None,
digital=None,
include_read_traffic=include_read_traffic,
auth_mode=auth_mode,
report_dir=str(report_dir),
require_success_rate=thresholds["require_success_rate"],
require_p95_seconds=thresholds["require_p95_seconds"],
require_p99_seconds=thresholds["require_p99_seconds"],
)
issues = evaluate_stage(
namespace=namespace,
report_dir=out_dir,
kubectl_context=kubectl_context,
thresholds=thresholds,
)
stage_payload = {
"profile": profile_name,
"report_dir": str(out_dir),
"thresholds": thresholds,
"results": summary["results"],
"passed": not issues,
"issues": issues,
}
stages.append(stage_payload)
if issues:
break
completed_at = time.strftime("%Y-%m-%dT%H:%M:%S")
payload = {
"namespace": namespace,
"base_url": base_url,
"auth_mode": auth_mode,
"include_read_traffic": include_read_traffic,
"started_at": started_at,
"completed_at": completed_at,
"stages": stages,
"passed": all(stage["passed"] for stage in stages) and len(stages) == len(profiles),
}
write_acceptance_pack(stage_root, payload)
return payload, stage_root
async def main() -> None:
parser = argparse.ArgumentParser(description="Run staged Track 7 validation and write an acceptance pack.")
parser.add_argument("--base-url", required=True, help="Gateway base URL")
parser.add_argument("--namespace", required=True, help="Kubernetes namespace to validate")
parser.add_argument(
"--profiles",
default="step_250_250,target_500_500",
help="Comma-separated staged profiles. Supported: step_250_250,target_500_500",
)
parser.add_argument(
"--auth-mode",
choices=["legacy_headers", "bearer"],
default="bearer",
help="Authentication mode for load traffic",
)
parser.add_argument(
"--include-read-traffic",
type=int,
choices=[0, 1],
default=1,
help="Include background supervisor/reporting reads during staged runs",
)
parser.add_argument("--kubectl-context", default=None, help="Optional kubectl context override")
parser.add_argument("--report-root", default=None, help="Root directory for stage artifacts")
args = parser.parse_args()
profiles = resolve_profiles(args.profiles)
payload, stage_root = await run_staged_validation(
base_url=args.base_url,
namespace=args.namespace,
profiles=profiles,
auth_mode=args.auth_mode,
include_read_traffic=bool(args.include_read_traffic),
kubectl_context=args.kubectl_context,
report_root=args.report_root,
)
print("Track 7 staged validation:")
print(json.dumps(payload, indent=2))
print(f"Acceptance pack: {stage_root}")
if not payload["passed"]:
raise SystemExit(1)
if __name__ == "__main__":
asyncio.run(main())
-104
View File
@@ -1,104 +0,0 @@
from __future__ import annotations
import argparse
import os
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from sqlalchemy import create_engine, inspect, select
from sqlalchemy.orm import Session
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.event_bus_smoke import run_smoke_check
from services.shared.db import DATABASE_URL, _normalize_database_url
from services.shared.sql_models import EventOutboxRow
def _engine_for(database_url: str | None):
return create_engine(_normalize_database_url(database_url or DATABASE_URL), future=True)
def _parse_iso(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
def evaluate_track8(
*,
database_url: str | None = None,
require_bus_enabled: bool = False,
base_url: str | None = None,
failed_age_seconds: int = 300,
) -> list[str]:
issues: list[str] = []
engine = _engine_for(database_url)
inspector = inspect(engine)
tables = set(inspector.get_table_names())
required_tables = {"event_outbox", "event_inbox", "reporting_event_log"}
missing = sorted(required_tables - tables)
if missing:
issues.append(f"Missing required tables: {', '.join(missing)}")
if require_bus_enabled and os.getenv("EVENT_BUS_ENABLED", "0").strip() not in {"1", "true", "yes", "on"}:
issues.append("EVENT_BUS_ENABLED is not enabled in the current environment")
with Session(engine) as session:
failed_rows = session.execute(
select(EventOutboxRow).where(EventOutboxRow.status == "failed")
).scalars().all()
stale_cutoff = datetime.now(timezone.utc) - timedelta(seconds=failed_age_seconds)
stale_failed = [
row for row in failed_rows if (_parse_iso(row.updated_at) or _parse_iso(row.created_at) or stale_cutoff) < stale_cutoff
]
if stale_failed:
issues.append(f"Found {len(stale_failed)} failed outbox events older than {failed_age_seconds}s")
pending_rows = session.execute(
select(EventOutboxRow).where(EventOutboxRow.status == "pending")
).scalars().all()
if len(pending_rows) > 500:
issues.append(f"Outbox backlog too high: {len(pending_rows)} pending events")
if not (ROOT / "contracts" / "events" / "ivr.completed.json").exists():
issues.append("Missing contracts/events/ivr.completed.json")
if base_url:
smoke = run_smoke_check(base_url=base_url, database_url=database_url)
if not smoke["passed"]:
issues.append("Event bus smoke check failed")
return issues
def main() -> None:
parser = argparse.ArgumentParser(description="Validate Track 8 event-bus readiness.")
parser.add_argument("--database-url", default=None)
parser.add_argument("--require-bus-enabled", action="store_true")
parser.add_argument("--base-url", default=None)
parser.add_argument("--failed-age-seconds", type=int, default=300)
args = parser.parse_args()
issues = evaluate_track8(
database_url=args.database_url,
require_bus_enabled=args.require_bus_enabled,
base_url=args.base_url,
failed_age_seconds=args.failed_age_seconds,
)
if issues:
print("[FAIL] Track 8 validation failed:")
for issue in issues:
print(f"- {issue}")
raise SystemExit(1)
print("[PASS] Track 8 validation checks passed")
if __name__ == "__main__":
main()
-967
View File
@@ -1,967 +0,0 @@
from __future__ import annotations
import argparse
import asyncio
import csv
from dataclasses import dataclass
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import time
from typing import Any
import httpx
ROOT = Path(__file__).resolve().parents[1]
DATA_ROOT = ROOT / ".data_uat_dry_run"
DEFAULT_OUTPUT_ROOT = ROOT / "docs" / "uat" / "evidence"
SCENARIO_SERVICE_MAP = {
"SYS": "platform",
"S0": "auth-service/gateway",
"S1": "customer-service",
"S2": "interaction-service",
"S3": "interaction-service/routing-service",
"S4": "routing-service/interaction-service",
"S5": "voice-adapter-service",
"S6": "telegram-adapter-service",
"S7": "kb-service",
"S8": "supervisor-service",
"S9": "reporting-service",
}
SEVERITY_RISK_MAP = {
"P1": "High",
"P2": "Medium",
"P3": "Medium",
"P4": "Low",
}
SERVICE_SPECS = [
{"name": "auth", "module": "services.auth_service.app:app", "port": 58001},
{"name": "audit", "module": "services.audit_service.app:app", "port": 58002},
{"name": "customer", "module": "services.customer_service.app:app", "port": 58003},
{"name": "interaction", "module": "services.interaction_service.app:app", "port": 58004},
{"name": "routing", "module": "services.routing_service.app:app", "port": 58005},
{"name": "voice", "module": "services.voice_adapter_service.app:app", "port": 58006},
{"name": "telegram", "module": "services.telegram_adapter_service.app:app", "port": 58007},
{"name": "kb", "module": "services.kb_service.app:app", "port": 58008},
{"name": "reporting", "module": "services.reporting_service.app:app", "port": 58009},
{"name": "supervisor", "module": "services.supervisor_service.app:app", "port": 58010},
{"name": "gateway", "module": "gateway.app:app", "port": 58080},
]
@dataclass
class ScenarioResult:
scenario_id: str
title: str
passed: bool
severity_on_fail: str
details: str
evidence: dict[str, Any]
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def now_compact() -> str:
return utc_now().strftime("%Y%m%d_%H%M%S")
def iso_now() -> str:
return utc_now().isoformat()
async def wait_for_health(base_url: str, retries: int = 80, delay: float = 0.2) -> None:
async with httpx.AsyncClient(timeout=2) as client:
for _ in range(retries):
try:
response = await client.get(f"{base_url}/health")
if response.status_code == 200:
return
except Exception:
pass
await asyncio.sleep(delay)
raise RuntimeError(f"Service not healthy: {base_url}/health")
def start_one_service(spec: dict[str, Any], data_dir: Path) -> subprocess.Popen:
env = os.environ.copy()
env["CC_DATA_DIR"] = str(data_dir)
if spec["name"] == "gateway":
env["AUTH_SERVICE_URL"] = "http://127.0.0.1:58001"
env["AUDIT_SERVICE_URL"] = "http://127.0.0.1:58002"
env["CUSTOMER_SERVICE_URL"] = "http://127.0.0.1:58003"
env["INTERACTION_SERVICE_URL"] = "http://127.0.0.1:58004"
env["ROUTING_SERVICE_URL"] = "http://127.0.0.1:58005"
env["VOICE_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:58006"
env["TELEGRAM_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:58007"
env["KB_SERVICE_URL"] = "http://127.0.0.1:58008"
env["REPORTING_SERVICE_URL"] = "http://127.0.0.1:58009"
env["SUPERVISOR_SERVICE_URL"] = "http://127.0.0.1:58010"
cmd = [
sys.executable,
"-m",
"uvicorn",
spec["module"],
"--host",
"127.0.0.1",
"--port",
str(spec["port"]),
]
return subprocess.Popen(
cmd,
cwd=str(ROOT),
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
async def start_services_sequential(data_dir: Path) -> list[subprocess.Popen]:
data_dir.mkdir(parents=True, exist_ok=True)
processes: list[subprocess.Popen] = []
for spec in SERVICE_SPECS:
proc = start_one_service(spec, data_dir)
processes.append(proc)
await wait_for_health(f"http://127.0.0.1:{spec['port']}")
return processes
def stop_services(processes: list[subprocess.Popen]) -> None:
for proc in processes:
if proc.poll() is not None:
continue
try:
proc.terminate()
except Exception:
pass
for proc in processes:
try:
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
def scenario_result(
scenario_id: str,
title: str,
passed: bool,
severity_on_fail: str,
details: str,
evidence: dict[str, Any] | None = None,
) -> ScenarioResult:
return ScenarioResult(
scenario_id=scenario_id,
title=title,
passed=passed,
severity_on_fail=severity_on_fail,
details=details,
evidence=evidence or {},
)
async def run_scenarios(base_url: str) -> tuple[list[ScenarioResult], dict[str, Any]]:
admin = {"X-User": "admin", "X-Role": "admin"}
supervisor = {"X-User": "supervisor", "X-Role": "supervisor"}
operator = {"X-User": "operator", "X-Role": "operator"}
analyst = {"X-User": "analyst", "X-Role": "analyst"}
results: list[ScenarioResult] = []
context: dict[str, Any] = {
"started_at": iso_now(),
"baseline_doc": "docs/gates/mvp-pilot-baseline.md",
"wave2_backlog": "docs/roadmap/05-wave2-backlog.md",
}
async with httpx.AsyncClient(base_url=base_url, timeout=12) as client:
health = await client.get("/health")
registry = await client.get("/registry")
if health.status_code != 200 or registry.status_code != 200:
details = f"health={health.status_code}, registry={registry.status_code}"
return [scenario_result("SYS", "System precheck", False, "P1", details)], context
services = registry.json().get("services", {})
context["reporting_url"] = services.get("reporting", "")
login = await client.post("/proxy/auth/auth/login", json={"username": "admin", "password": "admin123"})
login_ok = False
if login.status_code == 200:
login_ok = bool(login.json().get("access_token"))
denied = await client.post(
"/proxy/auth/users",
headers=operator,
json={
"username": f"uat_denied_{now_compact()}",
"password": "secret123",
"full_name": "UAT Denied",
"role": "operator",
},
)
results.append(
scenario_result(
"S0",
"Login and RBAC deny",
login_ok and denied.status_code == 403,
"P1",
f"login={login.status_code}, denied={denied.status_code}",
{"user": "admin", "denied_status": denied.status_code},
)
)
queue = await client.post(
"/proxy/routing/queues",
headers=admin,
json={
"name": f"UAT Dry Queue {now_compact()}",
"description": "UAT dry-run queue",
"rules": [
{"channel": "voice", "priority": 3, "strategy": "round_robin", "sla_seconds": 30},
{"channel": "telegram", "priority": 3, "strategy": "round_robin", "sla_seconds": 45},
],
},
)
if queue.status_code != 200:
results.append(
scenario_result(
"SYS",
"System precheck",
False,
"P1",
f"queue create failed status={queue.status_code}",
)
)
context["completed_at"] = iso_now()
return results, context
queue_id = queue.json()["queue_id"]
context["queue_id"] = queue_id
customer = await client.post(
"/proxy/customer/customers",
json={
"display_name": "UAT Dry Customer",
"phones": ["+77017770000"],
"preferred_phone": "+77017770000",
"tags": ["uat", "dry-run"],
},
)
customer_id = ""
search_ok = False
if customer.status_code == 200:
customer_id = customer.json()["customer_id"]
search = await client.get("/proxy/customer/customers?query=dry-run")
if search.status_code == 200:
search_ok = any(item.get("customer_id") == customer_id for item in search.json())
context["customer_id"] = customer_id
results.append(
scenario_result(
"S1",
"Customer create and search",
customer.status_code == 200 and search_ok,
"P1",
f"create={customer.status_code}, search={search_ok}",
{"customer_id": customer_id, "query": "dry-run"},
)
)
voice_interaction_id = ""
interaction = await client.post(
"/proxy/interaction/interactions",
headers=operator,
json={
"channel": "voice",
"subject": "UAT dry-run S2 voice",
"customer_id": customer_id or None,
"queue_id": queue_id,
"priority": 3,
},
)
if interaction.status_code != 200:
results.append(
scenario_result(
"S2",
"Voice interaction lifecycle",
False,
"P1",
f"create status={interaction.status_code}",
)
)
else:
voice_interaction_id = interaction.json()["interaction_id"]
context["voice_interaction_id"] = voice_interaction_id
assign = await client.patch(
f"/proxy/interaction/interactions/{voice_interaction_id}/assign",
headers=supervisor,
json={"assignee": "operator_a"},
)
close = await client.patch(
f"/proxy/interaction/interactions/{voice_interaction_id}/status",
headers=operator,
json={"status": "closed"},
)
timeline = await client.get(f"/proxy/interaction/interactions/{voice_interaction_id}/timeline")
actions: set[str] = set()
if timeline.status_code == 200:
actions = {event.get("action", "") for event in timeline.json().get("events", [])}
expected = {"interaction.created", "interaction.assigned", "interaction.status_changed"}
results.append(
scenario_result(
"S2",
"Voice interaction lifecycle",
assign.status_code == 200 and close.status_code == 200 and expected.issubset(actions),
"P1",
f"assign={assign.status_code}, close={close.status_code}, actions={sorted(actions)}",
{"interaction_id": voice_interaction_id},
)
)
esc_create = await client.post(
"/proxy/interaction/interactions",
headers=operator,
json={
"channel": "voice",
"subject": "UAT dry-run S3 escalation",
"customer_id": customer_id or None,
"queue_id": queue_id,
"priority": 3,
},
)
if esc_create.status_code != 200:
results.append(
scenario_result(
"S3",
"Assignment and escalation to second line",
False,
"P1",
f"create status={esc_create.status_code}",
)
)
else:
esc_interaction_id = esc_create.json()["interaction_id"]
assign = await client.patch(
f"/proxy/interaction/interactions/{esc_interaction_id}/assign",
headers=supervisor,
json={"assignee": "operator_b"},
)
escalated = await client.post(
f"/proxy/interaction/interactions/{esc_interaction_id}/escalate",
headers=operator,
json={"target_queue_id": "line2"},
)
timeline = await client.get(f"/proxy/interaction/interactions/{esc_interaction_id}/timeline")
actions = []
if timeline.status_code == 200:
actions = [event.get("action", "") for event in timeline.json().get("events", [])]
results.append(
scenario_result(
"S3",
"Assignment and escalation to second line",
assign.status_code == 200
and escalated.status_code == 200
and escalated.json().get("status") == "escalated"
and escalated.json().get("queue_id") == "line2"
and "interaction.escalated" in actions,
"P1",
f"assign={assign.status_code}, escalate={escalated.status_code}, queue={escalated.json().get('queue_id') if escalated.status_code == 200 else ''}",
{"interaction_id": esc_interaction_id, "target_queue": "line2"},
)
)
route = await client.post(f"/proxy/routing/queues/{queue_id}/route?channel=voice&priority=3")
timeline = (
await client.get(f"/proxy/interaction/interactions/{voice_interaction_id}/timeline")
if voice_interaction_id
else None
)
timeline_ok = False
timeline_count = 0
if timeline and timeline.status_code == 200:
timeline_count = len(timeline.json().get("events", []))
timeline_ok = timeline_count >= 3
route_ok = False
if route.status_code == 200:
route_ok = bool(route.json().get("assignee"))
results.append(
scenario_result(
"S4",
"Routing and timeline verification",
route_ok and timeline_ok,
"P2",
f"route={route.status_code}, timeline_ok={timeline_ok}, timeline_events={timeline_count}",
{
"queue_id": queue_id,
"routed_assignee": route.json().get("assignee") if route.status_code == 200 else "",
"interaction_id": voice_interaction_id,
},
)
)
voice_event = await client.post(
"/proxy/voice/integrations/voice/events",
headers=operator,
json={
"event_type": "call.started",
"call_id": f"uat_call_{now_compact()}",
"interaction_id": voice_interaction_id or None,
"payload": {"source": "uat_dry_run"},
},
)
voice_event_id = voice_event.json().get("event_id") if voice_event.status_code == 200 else ""
voice_list_ok = False
if voice_event.status_code == 200:
voice_events = await client.get(
"/proxy/voice/integrations/voice/events?limit=10",
headers=supervisor,
)
if voice_events.status_code == 200:
voice_list_ok = any(item.get("event_id") == voice_event_id for item in voice_events.json())
results.append(
scenario_result(
"S5",
"Voice event intake",
voice_event.status_code == 200 and voice_list_ok,
"P2",
f"create={voice_event.status_code}, listed={voice_list_ok}",
{"event_id": voice_event_id},
)
)
telegram = await client.post(
"/proxy/telegram/integrations/telegram/webhook",
json={
"chat_id": f"uat_dry_chat_{now_compact()}",
"text": "UAT dry-run telegram",
"payload": {"source": "uat_dry_run"},
},
)
telegram_id = telegram.json().get("message_id") if telegram.status_code == 200 else ""
telegram_list_ok = False
if telegram.status_code == 200:
messages = await client.get("/proxy/telegram/integrations/telegram/messages?limit=10")
if messages.status_code == 200:
telegram_list_ok = any(item.get("message_id") == telegram_id for item in messages.json())
results.append(
scenario_result(
"S6",
"Telegram interaction lifecycle",
telegram.status_code == 200 and telegram_list_ok,
"P1",
f"create={telegram.status_code}, listed={telegram_list_ok}",
{"message_id": telegram_id},
)
)
category = await client.post(
"/proxy/kb/knowledge/categories",
headers=analyst,
json={"name": f"UAT Dry {now_compact()}", "description": "dry-run"},
)
if category.status_code != 200:
results.append(
scenario_result(
"S7",
"KB usage in active handling",
False,
"P2",
f"category create status={category.status_code}",
)
)
else:
category_id = category.json()["category_id"]
article = await client.post(
"/proxy/kb/knowledge/articles",
headers=analyst,
json={
"category_id": category_id,
"title": "UAT dry KB article",
"body": "KB article body for dry run",
"tags": ["uat", "kb", "dry"],
},
)
search = await client.get("/proxy/kb/knowledge/search?q=dry")
article_id = article.json().get("article_id") if article.status_code == 200 else ""
found = False
if search.status_code == 200:
found = any(item.get("article_id") == article_id for item in search.json())
results.append(
scenario_result(
"S7",
"KB usage in active handling",
article.status_code == 200 and search.status_code == 200 and found,
"P2",
f"article={article.status_code}, search={search.status_code}, found={found}",
{"article_id": article_id, "keyword": "dry"},
)
)
up1 = await client.post(
"/proxy/supervisor/supervisor/agent-states",
json={"agent_id": "uat_dry_a1", "state": "READY", "queue_id": queue_id},
)
up2 = await client.post(
"/proxy/supervisor/supervisor/agent-states",
json={"agent_id": "uat_dry_a2", "state": "BUSY", "queue_id": queue_id},
)
metrics = await client.post(
f"/proxy/supervisor/supervisor/queue-metrics?queue_id={queue_id}&in_queue=2&avg_wait_seconds=19"
)
realtime = await client.get("/proxy/supervisor/supervisor/realtime")
queue_seen = False
agent_total = 0
if realtime.status_code == 200:
body = realtime.json()
agent_total = int(body.get("agents", {}).get("total", 0))
queue_seen = any(row.get("queue_id") == queue_id for row in body.get("queues", []))
results.append(
scenario_result(
"S8",
"Supervisor realtime",
up1.status_code == 200
and up2.status_code == 200
and metrics.status_code == 200
and realtime.status_code == 200
and agent_total >= 2
and queue_seen,
"P2",
f"state_updates=({up1.status_code},{up2.status_code}), metrics={metrics.status_code}, realtime={realtime.status_code}",
{"queue_id": queue_id, "agent_total": agent_total},
)
)
kpi_rows = [
{
"queue_id": queue_id,
"answered": True,
"wait_seconds": 15,
"handle_seconds": 90,
"abandoned": False,
"resolved_first_contact": True,
},
{
"queue_id": queue_id,
"answered": True,
"wait_seconds": 33,
"handle_seconds": 110,
"abandoned": False,
"resolved_first_contact": False,
},
{
"queue_id": queue_id,
"answered": False,
"wait_seconds": 10,
"handle_seconds": 0,
"abandoned": True,
"resolved_first_contact": False,
},
]
ingest_statuses: list[int] = []
for row in kpi_rows:
ingested = await client.post("/proxy/reporting/reports/events", json=row)
ingest_statuses.append(ingested.status_code)
kpi = await client.get(f"/proxy/reporting/reports/kpi?queue_id={queue_id}&sl_threshold_seconds=30")
has_kpi = False
if kpi.status_code == 200:
payload = kpi.json().get("kpi", {})
has_kpi = {"SL", "ASA", "AHT", "Abandon", "FCR"}.issubset(set(payload.keys()))
exported = await client.get("/proxy/reporting/reports/export")
export_raw = exported.json().get("raw", "") if exported.status_code == 200 else ""
csv_ok = (
exported.status_code == 200
and "queue_id,answered,wait_seconds,handle_seconds,abandoned,resolved_first_contact,created_at" in export_raw
and queue_id in export_raw
)
results.append(
scenario_result(
"S9",
"KPI report and export validation",
all(code == 200 for code in ingest_statuses) and kpi.status_code == 200 and has_kpi and csv_ok,
"P2",
f"ingest={ingest_statuses}, kpi={kpi.status_code}, csv={csv_ok}",
{"queue_id": queue_id},
)
)
context["completed_at"] = iso_now()
return results, context
def build_defects(results: list[ScenarioResult]) -> list[dict[str, str]]:
defects: list[dict[str, str]] = []
stamp = iso_now()
counter = 1
for row in results:
if row.passed:
continue
defect_id = f"UAT-DRY-{counter:03d}"
counter += 1
severity = row.severity_on_fail
defects.append(
{
"defect_id": defect_id,
"severity": severity,
"status": "Open",
"scenario": row.scenario_id,
"service": SCENARIO_SERVICE_MAP.get(row.scenario_id, "platform"),
"risk_level": SEVERITY_RISK_MAP.get(severity, "Medium"),
"summary": row.title,
"repro_steps": "See scenario checklist and automated dry-run logs",
"actual_result": row.details,
"expected_result": "Scenario should pass",
"verification_step": f"Re-run scenario {row.scenario_id} after fix",
"owner": "TBD",
"opened_at": stamp,
"closed_at": "",
"backlog_bucket": "MVP" if severity in {"P1", "P2"} else "Wave2",
"notes": "Generated by scripts/uat_dry_run.py",
}
)
return defects
def write_scenario_json(output_dir: Path, results: list[ScenarioResult], context: dict[str, Any]) -> Path:
payload = {
"generated_at": iso_now(),
"context": context,
"scenarios": [
{
"scenario_id": row.scenario_id,
"title": row.title,
"passed": row.passed,
"severity_on_fail": row.severity_on_fail,
"details": row.details,
"evidence": row.evidence,
}
for row in results
],
}
path = output_dir / "scenario-results.json"
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return path
def write_defect_csv(output_dir: Path, defects: list[dict[str, str]]) -> Path:
path = output_dir / "defect-log.csv"
fieldnames = [
"defect_id",
"severity",
"status",
"scenario",
"service",
"risk_level",
"summary",
"repro_steps",
"actual_result",
"expected_result",
"verification_step",
"owner",
"opened_at",
"closed_at",
"backlog_bucket",
"notes",
]
with path.open("w", newline="", encoding="utf-8") as fp:
writer = csv.DictWriter(fp, fieldnames=fieldnames)
writer.writeheader()
for row in defects:
writer.writerow(row)
return path
def write_session_protocol(
output_dir: Path,
session_id: str,
base_url: str,
results: list[ScenarioResult],
defects: list[dict[str, str]],
build_version: str,
deployment_date: str,
cluster_namespace: str,
) -> Path:
passed = sum(1 for row in results if row.passed)
failed = sum(1 for row in results if not row.passed)
p1 = sum(1 for row in defects if row["severity"] == "P1")
p2 = sum(1 for row in defects if row["severity"] == "P2")
p3 = sum(1 for row in defects if row["severity"] == "P3")
p4 = sum(1 for row in defects if row["severity"] == "P4")
lines: list[str] = []
lines.append("# UAT Session Protocol (Automated Dry Run)")
lines.append("")
lines.append("## Session Metadata")
lines.append("")
lines.append(f"- Session ID: {session_id}")
lines.append(f"- Date: {utc_now().date().isoformat()}")
lines.append(f"- Start time (UTC): {iso_now()}")
lines.append(f"- End time (UTC): {iso_now()}")
lines.append(f"- Environment URL: {base_url}")
lines.append(f"- Build/version: {build_version}")
lines.append(f"- Deployment date: {deployment_date}")
lines.append(f"- Cluster/namespace: {cluster_namespace}")
lines.append("")
lines.append("## Scope Confirmation")
lines.append("")
lines.append("- [x] MVP pilot scope reviewed against `docs/gates/mvp-pilot-baseline.md`")
lines.append("- [x] Out-of-scope requests remain deferred to `docs/roadmap/05-wave2-backlog.md`")
lines.append("- [x] API stabilization rules remain in effect (backward-compatible fixes only)")
lines.append("")
lines.append("## Participants")
lines.append("")
lines.append("- Operators: automated dry-run actor")
lines.append("- Supervisor: automated dry-run actor")
lines.append("- Analyst: automated dry-run actor")
lines.append("- Admin: automated dry-run actor")
lines.append("- Business owner: pending manual UAT")
lines.append("- IT owner: pending manual UAT")
lines.append("")
lines.append("## Preconditions")
lines.append("")
lines.append("- [x] Environment is reachable")
lines.append("- [x] Test accounts are active")
lines.append("- [x] Test queue is configured")
lines.append("- [x] Audit and reporting endpoints are reachable")
lines.append("")
lines.append("## Execution Summary")
lines.append("")
lines.append(f"- Mandatory scenarios executed: {len(results)}")
lines.append(f"- Passed: {passed}")
lines.append(f"- Failed: {failed}")
lines.append("- Blocked: 0")
lines.append("")
lines.append("## Scenario Results")
lines.append("")
for row in results:
mark = "PASS" if row.passed else "FAIL"
lines.append(f"- [{mark}] {row.scenario_id} {row.title}: {row.details}")
lines.append("")
lines.append("## Defect Summary")
lines.append("")
lines.append(f"- P1: {p1}")
lines.append(f"- P2: {p2}")
lines.append(f"- P3: {p3}")
lines.append(f"- P4: {p4}")
lines.append("- Defect log attachment: defect-log.csv")
lines.append("")
lines.append("## Decision")
lines.append("")
if failed == 0:
lines.append("- [x] Accepted with conditions")
lines.append("- [ ] Accepted for pilot completion")
lines.append("- [ ] Not accepted")
else:
lines.append("- [ ] Accepted with conditions")
lines.append("- [ ] Accepted for pilot completion")
lines.append("- [x] Not accepted")
lines.append("")
lines.append("## Comments")
lines.append("")
lines.append("- Automated dry-run completed.")
lines.append("- Manual UAT with real operators/supervisor is still required for final sign-off.")
lines.append("- Any future scope additions must be recorded in `docs/roadmap/05-wave2-backlog.md`.")
lines.append("")
lines.append("## Signatures")
lines.append("")
lines.append("- Business owner: pending")
lines.append("- IT owner: pending")
lines.append("- Supervisor representative: pending")
lines.append(f"- Date: {utc_now().date().isoformat()}")
lines.append("")
path = output_dir / "session-protocol.md"
path.write_text("\n".join(lines), encoding="utf-8")
return path
def write_signoff_draft(output_dir: Path, session_id: str, failed: int) -> Path:
lines: list[str] = []
lines.append("# UAT Sign-off Sheet (Draft)")
lines.append("")
lines.append(f"- Session ID: {session_id}")
lines.append(f"- Date: {utc_now().date().isoformat()}")
lines.append(f"- Scenario failures: {failed}")
lines.append("")
lines.append("## Scope Confirmation")
lines.append("")
lines.append("- MVP pilot scope confirmed against `docs/gates/mvp-pilot-baseline.md`")
lines.append("- Out-of-scope requests remain deferred to `docs/roadmap/05-wave2-backlog.md`")
lines.append("")
lines.append("## Acceptance Statement")
lines.append("")
lines.append("This draft is generated by automated dry-run and cannot replace real UAT signatures.")
lines.append("")
lines.append("## Required Manual Signatures")
lines.append("")
lines.append("- Business owner: pending")
lines.append("- IT owner: pending")
lines.append("- Supervisor representative: pending")
lines.append("")
path = output_dir / "signoff-draft.md"
path.write_text("\n".join(lines), encoding="utf-8")
return path
def write_summary(
output_dir: Path,
session_id: str,
base_url: str,
scenario_file: Path,
defect_file: Path,
protocol_file: Path,
signoff_file: Path,
results: list[ScenarioResult],
defects: list[dict[str, str]],
) -> Path:
passed = sum(1 for row in results if row.passed)
failed = sum(1 for row in results if not row.passed)
p1 = sum(1 for row in defects if row["severity"] == "P1")
p2 = sum(1 for row in defects if row["severity"] == "P2")
lines: list[str] = []
lines.append("# UAT Dry-Run Summary")
lines.append("")
lines.append(f"- Session ID: {session_id}")
lines.append(f"- Generated at (UTC): {iso_now()}")
lines.append(f"- Environment URL: {base_url}")
lines.append(f"- Scenarios: {len(results)} total, {passed} passed, {failed} failed")
lines.append(f"- Defects: P1={p1}, P2={p2}")
lines.append("")
lines.append("## Files")
lines.append("")
lines.append(f"- Scenario results: `{scenario_file.name}`")
lines.append(f"- Session protocol: `{protocol_file.name}`")
lines.append(f"- Defect log: `{defect_file.name}`")
lines.append(f"- Sign-off draft: `{signoff_file.name}`")
lines.append("")
lines.append("## Next Step")
lines.append("")
lines.append("Run manual UAT with real participants, fix only P1/P2 items, and collect signatures.")
lines.append("Route feature requests and deferred scope to `docs/roadmap/05-wave2-backlog.md`.")
lines.append("")
path = output_dir / "summary.md"
path.write_text("\n".join(lines), encoding="utf-8")
return path
def update_p1p2_register(defects: list[dict[str, str]], session_id: str) -> None:
p1 = sum(1 for row in defects if row["severity"] == "P1" and row["status"] == "Open")
p2 = sum(1 for row in defects if row["severity"] == "P2" and row["status"] == "Open")
path = ROOT / "docs" / "gates" / "p1-p2-defects.md"
lines: list[str] = []
lines.append("# P1/P2 Defect Register (Pilot)")
lines.append("")
lines.append(f"Last update: {utc_now().date().isoformat()} ({session_id})")
lines.append("")
lines.append("## Current Status")
lines.append(f"- Open P1: {p1}")
lines.append(f"- Open P2: {p2}")
lines.append("- Source: `scripts/uat_dry_run.py` and automated checks")
lines.append(f"- Latest dry-run evidence: `docs/uat/evidence/dry_run_{session_id}/`")
lines.append("")
lines.append("## Triage Policy")
lines.append("- Only `P1` and `P2` issues belong to MVP remediation.")
lines.append("- `P3` and `P4` issues move to `docs/roadmap/05-wave2-backlog.md` unless they block sign-off.")
lines.append("")
lines.append("## Pilot Note")
lines.append("- Final P1/P2 closure is confirmed only after the real-operator UAT cycle and sign-off.")
path.write_text("\n".join(lines), encoding="utf-8")
async def main() -> None:
parser = argparse.ArgumentParser(description="Run automated UAT dry-run and generate session artifacts")
parser.add_argument("--base-url", default="http://localhost:8080", help="Gateway base URL")
parser.add_argument("--auto-start", action="store_true", help="Auto-start local services for dry-run")
parser.add_argument("--keep-data", action="store_true", help="Keep temporary auto-start data directory")
parser.add_argument(
"--output-dir",
default="",
help="Output root directory (default: docs/uat/evidence)",
)
parser.add_argument("--session-id", default="", help="Optional session ID override")
parser.add_argument("--build-version", default="v1.0.0-mvp", help="Build/version for protocol")
parser.add_argument(
"--deployment-date",
default=utc_now().date().isoformat(),
help="Deployment date for protocol",
)
parser.add_argument("--cluster-namespace", default="local/standalone", help="Cluster/namespace for protocol")
parser.add_argument(
"--update-defect-register",
action="store_true",
help="Update docs/gates/p1-p2-defects.md using dry-run result",
)
args = parser.parse_args()
output_root = Path(args.output_dir) if args.output_dir else DEFAULT_OUTPUT_ROOT
output_root = output_root if output_root.is_absolute() else (ROOT / output_root).resolve()
session_id = args.session_id.strip() or f"UAT-DRY-{now_compact()}"
session_dir = output_root / f"dry_run_{session_id}"
session_dir.mkdir(parents=True, exist_ok=True)
run_data_dir = DATA_ROOT / f"run_{int(time.time() * 1000)}"
processes: list[subprocess.Popen] = []
base_url = args.base_url
try:
if args.auto_start:
processes = await start_services_sequential(run_data_dir)
base_url = "http://127.0.0.1:58080"
results, context = await run_scenarios(base_url)
defects = build_defects(results)
scenario_file = write_scenario_json(session_dir, results, context)
defect_file = write_defect_csv(session_dir, defects)
protocol_file = write_session_protocol(
session_dir,
session_id,
base_url,
results,
defects,
args.build_version,
args.deployment_date,
args.cluster_namespace,
)
signoff_file = write_signoff_draft(session_dir, session_id, sum(1 for row in results if not row.passed))
summary_file = write_summary(
session_dir,
session_id,
base_url,
scenario_file,
defect_file,
protocol_file,
signoff_file,
results,
defects,
)
if args.update_defect_register:
update_p1p2_register(defects, session_id)
passed = sum(1 for row in results if row.passed)
failed = sum(1 for row in results if not row.passed)
print(f"UAT dry-run session: {session_id}")
print(f"Output directory: {session_dir}")
print(f"Scenarios: total={len(results)} passed={passed} failed={failed}")
print(f"Summary: {summary_file}")
sys.exit(0 if failed == 0 else 1)
finally:
stop_services(processes)
if args.auto_start and not args.keep_data:
try:
if run_data_dir.exists():
shutil.rmtree(run_data_dir)
except Exception:
pass
if __name__ == "__main__":
asyncio.run(main())
-329
View File
@@ -1,329 +0,0 @@
from __future__ import annotations
import argparse
import json
from datetime import datetime, timezone
from pathlib import Path
import shutil
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_EVIDENCE_ROOT = ROOT / "docs" / "uat" / "evidence"
DEFAULT_TEMPLATE_ROOT = ROOT / "docs" / "uat"
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def now_compact() -> str:
return utc_now().strftime("%Y%m%d_%H%M%S")
def iso_now() -> str:
return utc_now().isoformat()
def find_latest_preflight(evidence_root: Path) -> Path:
candidates = sorted(
evidence_root.glob("preflight_*.md"),
key=lambda path: path.stat().st_mtime,
reverse=True,
)
if not candidates:
raise FileNotFoundError("No preflight report found in docs/uat/evidence")
return candidates[0]
def find_latest_dry_run_dir(evidence_root: Path) -> Path:
candidates = sorted(
[path for path in evidence_root.glob("dry_run_*") if path.is_dir()],
key=lambda path: path.stat().st_mtime,
reverse=True,
)
if not candidates:
raise FileNotFoundError("No dry-run directory found in docs/uat/evidence")
return candidates[0]
def _replace_line(content: str, prefix: str, value: str) -> str:
replacement = f"{prefix} {value}".rstrip()
return content.replace(prefix, replacement, 1)
def render_session_protocol(
template_text: str,
*,
session_id: str,
environment_url: str,
build_version: str,
deployment_date: str,
cluster_namespace: str,
participants: dict[str, str],
preflight_name: str,
defect_log_name: str,
) -> str:
content = template_text
today = utc_now().date().isoformat()
content = _replace_line(content, "- Session ID:", session_id)
content = _replace_line(content, "- Date:", today)
content = _replace_line(content, "- Start time:", "")
content = _replace_line(content, "- End time:", "")
content = _replace_line(content, "- Environment URL:", environment_url)
content = _replace_line(content, "- Build/version:", build_version)
content = _replace_line(content, "- Deployment date:", deployment_date)
content = _replace_line(content, "- Cluster/namespace:", cluster_namespace)
field_map = {
"- Operators:": participants["operators"],
"- Supervisor:": participants["supervisor"],
"- Analyst:": participants["analyst"],
"- Admin:": participants["admin"],
"- Business owner:": participants["business_owner"],
"- IT owner:": participants["it_owner"],
}
for prefix, value in field_map.items():
content = _replace_line(content, prefix, value)
content = content.replace(
"- [ ] Preflight report attached (`docs/uat/evidence/preflight_*.md`)",
f"- [x] Preflight report attached (`attachments/{preflight_name}`)",
1,
)
content = content.replace(
"- [ ] Defect log prepared from `docs/uat/defect-log-template.csv`",
f"- [x] Defect log prepared from `{defect_log_name}`",
1,
)
return content
def render_signoff_sheet(
template_text: str,
*,
session_id: str,
environment_url: str,
build_version: str,
) -> str:
lines = template_text.splitlines()
if not lines:
return template_text
header = [
lines[0],
"",
f"- Session ID: {session_id}",
f"- Environment URL: {environment_url}",
f"- Build/version: {build_version}",
f"- Prepared at (UTC): {iso_now()}",
"",
]
return "\n".join(header + lines[1:])
def build_manual_readme(
*,
session_id: str,
environment_url: str,
preflight_name: str,
dry_run_name: str,
) -> str:
lines = [
"# Manual UAT Session Bundle",
"",
f"- Session ID: {session_id}",
f"- Environment URL: {environment_url}",
f"- Prepared at (UTC): {iso_now()}",
"",
"## Included Files",
"",
"- `session-protocol.md`",
"- `scenario-checklist.md`",
"- `defect-log.csv`",
"- `signoff-sheet.md`",
"- `manifest.json`",
"",
"## Attached Evidence",
"",
f"- `attachments/{preflight_name}`",
f"- `attachments/{dry_run_name}/`",
"",
"## Next Step",
"",
"Run the live manual UAT on the pilot gateway, record any defects, and use the",
"`scripts/finalize_mvp_pilot.py` command only after signatures are captured and",
"all open P1/P2 defects are closed.",
]
return "\n".join(lines)
def prepare_manual_bundle(
*,
session_id: str,
environment_url: str,
build_version: str,
deployment_date: str,
cluster_namespace: str,
participants: dict[str, str],
output_root: Path,
template_root: Path,
preflight_report: Path,
dry_run_dir: Path,
overwrite: bool = False,
) -> Path:
session_dir = output_root / f"manual_{session_id}"
if session_dir.exists():
if not overwrite:
raise FileExistsError(f"Session directory already exists: {session_dir}")
shutil.rmtree(session_dir)
attachments_dir = session_dir / "attachments"
attachments_dir.mkdir(parents=True, exist_ok=True)
copied_preflight = attachments_dir / preflight_report.name
shutil.copy2(preflight_report, copied_preflight)
copied_dry_run = attachments_dir / dry_run_dir.name
shutil.copytree(dry_run_dir, copied_dry_run)
session_template = (template_root / "session-template.md").read_text(encoding="utf-8")
signoff_template = (template_root / "signoff-template.md").read_text(encoding="utf-8")
checklist_template = (template_root / "scenario-checklist.md").read_text(encoding="utf-8")
defect_template = (template_root / "defect-log-template.csv").read_text(encoding="utf-8")
rendered_session = render_session_protocol(
session_template,
session_id=session_id,
environment_url=environment_url,
build_version=build_version,
deployment_date=deployment_date,
cluster_namespace=cluster_namespace,
participants=participants,
preflight_name=preflight_report.name,
defect_log_name="defect-log.csv",
)
rendered_signoff = render_signoff_sheet(
signoff_template,
session_id=session_id,
environment_url=environment_url,
build_version=build_version,
)
(session_dir / "session-protocol.md").write_text(rendered_session, encoding="utf-8")
(session_dir / "signoff-sheet.md").write_text(rendered_signoff, encoding="utf-8")
(session_dir / "scenario-checklist.md").write_text(checklist_template, encoding="utf-8")
(session_dir / "defect-log.csv").write_text(defect_template, encoding="utf-8")
manifest: dict[str, Any] = {
"prepared_at": iso_now(),
"session_id": session_id,
"environment_url": environment_url,
"build_version": build_version,
"deployment_date": deployment_date,
"cluster_namespace": cluster_namespace,
"participants": participants,
"artifacts": {
"preflight_report": f"attachments/{preflight_report.name}",
"dry_run_bundle": f"attachments/{dry_run_dir.name}",
"session_protocol": "session-protocol.md",
"scenario_checklist": "scenario-checklist.md",
"defect_log": "defect-log.csv",
"signoff_sheet": "signoff-sheet.md",
},
}
(session_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
(session_dir / "README.md").write_text(
build_manual_readme(
session_id=session_id,
environment_url=environment_url,
preflight_name=preflight_report.name,
dry_run_name=dry_run_dir.name,
),
encoding="utf-8",
)
return session_dir
def main() -> None:
parser = argparse.ArgumentParser(description="Prepare a manual UAT bundle from the latest preflight and dry-run")
parser.add_argument("--session-id", default=f"UAT-MANUAL-{now_compact()}", help="Manual UAT session id")
parser.add_argument(
"--environment-url",
default="http://<pilot-gateway>:8080",
help="Pilot gateway URL recorded in the session pack",
)
parser.add_argument("--build-version", default="v1.0.0-mvp", help="Build/version under test")
parser.add_argument(
"--deployment-date",
default=utc_now().date().isoformat(),
help="Deployment date recorded in the session pack",
)
parser.add_argument(
"--cluster-namespace",
default="pilot/production-like",
help="Cluster or namespace label recorded in the session pack",
)
parser.add_argument("--operators", default="TBD", help="Operator participants")
parser.add_argument("--supervisor", default="TBD", help="Supervisor participant")
parser.add_argument("--analyst", default="TBD", help="Analyst participant")
parser.add_argument("--admin", default="TBD", help="Admin participant")
parser.add_argument("--business-owner", default="TBD", help="Business owner")
parser.add_argument("--it-owner", default="TBD", help="IT owner")
parser.add_argument(
"--preflight-report",
default="",
help="Optional explicit preflight report path (default: latest preflight report)",
)
parser.add_argument(
"--dry-run-dir",
default="",
help="Optional explicit dry-run directory path (default: latest dry-run evidence folder)",
)
parser.add_argument(
"--output-root",
default="",
help="Output root directory (default: docs/uat/evidence)",
)
parser.add_argument("--overwrite", action="store_true", help="Overwrite the session folder if it already exists")
args = parser.parse_args()
output_root = Path(args.output_root) if args.output_root else DEFAULT_EVIDENCE_ROOT
output_root = output_root if output_root.is_absolute() else (ROOT / output_root).resolve()
preflight_report = Path(args.preflight_report) if args.preflight_report else find_latest_preflight(DEFAULT_EVIDENCE_ROOT)
if not preflight_report.is_absolute():
preflight_report = (ROOT / preflight_report).resolve()
dry_run_dir = Path(args.dry_run_dir) if args.dry_run_dir else find_latest_dry_run_dir(DEFAULT_EVIDENCE_ROOT)
if not dry_run_dir.is_absolute():
dry_run_dir = (ROOT / dry_run_dir).resolve()
participants = {
"operators": args.operators,
"supervisor": args.supervisor,
"analyst": args.analyst,
"admin": args.admin,
"business_owner": args.business_owner,
"it_owner": args.it_owner,
}
session_dir = prepare_manual_bundle(
session_id=args.session_id,
environment_url=args.environment_url,
build_version=args.build_version,
deployment_date=args.deployment_date,
cluster_namespace=args.cluster_namespace,
participants=participants,
output_root=output_root,
template_root=DEFAULT_TEMPLATE_ROOT,
preflight_report=preflight_report,
dry_run_dir=dry_run_dir,
overwrite=args.overwrite,
)
print(f"Manual UAT bundle prepared: {session_dir}")
if __name__ == "__main__":
main()
-457
View File
@@ -1,457 +0,0 @@
from __future__ import annotations
import argparse
import asyncio
from dataclasses import dataclass
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import time
from typing import Any
import httpx
ROOT = Path(__file__).resolve().parents[1]
DATA_ROOT = ROOT / ".data_uat_preflight"
DEFAULT_EVIDENCE_DIR = ROOT / "docs" / "uat" / "evidence"
SERVICE_SPECS = [
{"name": "auth", "module": "services.auth_service.app:app", "port": 48001},
{"name": "audit", "module": "services.audit_service.app:app", "port": 48002},
{"name": "customer", "module": "services.customer_service.app:app", "port": 48003},
{"name": "interaction", "module": "services.interaction_service.app:app", "port": 48004},
{"name": "routing", "module": "services.routing_service.app:app", "port": 48005},
{"name": "voice", "module": "services.voice_adapter_service.app:app", "port": 48006},
{"name": "telegram", "module": "services.telegram_adapter_service.app:app", "port": 48007},
{"name": "kb", "module": "services.kb_service.app:app", "port": 48008},
{"name": "reporting", "module": "services.reporting_service.app:app", "port": 48009},
{"name": "supervisor", "module": "services.supervisor_service.app:app", "port": 48010},
{"name": "gateway", "module": "gateway.app:app", "port": 48080},
]
REQUIRED_SERVICES = [
"auth",
"audit",
"customer",
"interaction",
"routing",
"voice",
"telegram",
"kb",
"reporting",
"supervisor",
]
@dataclass
class CheckResult:
name: str
ok: bool
details: str
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def now_compact() -> str:
return utc_now().strftime("%Y%m%d_%H%M%S")
def iso_now() -> str:
return utc_now().isoformat()
async def wait_for_health(base_url: str, retries: int = 80, delay: float = 0.2) -> None:
async with httpx.AsyncClient(timeout=2) as client:
for _ in range(retries):
try:
response = await client.get(f"{base_url}/health")
if response.status_code == 200:
return
except Exception:
pass
await asyncio.sleep(delay)
raise RuntimeError(f"Service not healthy: {base_url}/health")
def start_one_service(spec: dict[str, Any], data_dir: Path) -> subprocess.Popen:
env = os.environ.copy()
env["CC_DATA_DIR"] = str(data_dir)
if spec["name"] == "gateway":
env["AUTH_SERVICE_URL"] = "http://127.0.0.1:48001"
env["AUDIT_SERVICE_URL"] = "http://127.0.0.1:48002"
env["CUSTOMER_SERVICE_URL"] = "http://127.0.0.1:48003"
env["INTERACTION_SERVICE_URL"] = "http://127.0.0.1:48004"
env["ROUTING_SERVICE_URL"] = "http://127.0.0.1:48005"
env["VOICE_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:48006"
env["TELEGRAM_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:48007"
env["KB_SERVICE_URL"] = "http://127.0.0.1:48008"
env["REPORTING_SERVICE_URL"] = "http://127.0.0.1:48009"
env["SUPERVISOR_SERVICE_URL"] = "http://127.0.0.1:48010"
cmd = [
sys.executable,
"-m",
"uvicorn",
spec["module"],
"--host",
"127.0.0.1",
"--port",
str(spec["port"]),
]
return subprocess.Popen(
cmd,
cwd=str(ROOT),
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
async def start_services_sequential(data_dir: Path) -> list[subprocess.Popen]:
data_dir.mkdir(parents=True, exist_ok=True)
processes: list[subprocess.Popen] = []
for spec in SERVICE_SPECS:
proc = start_one_service(spec, data_dir)
processes.append(proc)
await wait_for_health(f"http://127.0.0.1:{spec['port']}")
return processes
def stop_services(processes: list[subprocess.Popen]) -> None:
for proc in processes:
if proc.poll() is not None:
continue
try:
proc.terminate()
except Exception:
pass
for proc in processes:
try:
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
async def run_checks(base_url: str) -> tuple[list[CheckResult], dict[str, Any]]:
checks: list[CheckResult] = []
artifacts: dict[str, Any] = {}
admin = {"X-User": "admin", "X-Role": "admin"}
supervisor = {"X-User": "supervisor", "X-Role": "supervisor"}
operator = {"X-User": "operator", "X-Role": "operator"}
analyst = {"X-User": "analyst", "X-Role": "analyst"}
async with httpx.AsyncClient(base_url=base_url, timeout=10) as client:
try:
health = await client.get("/health")
ok = health.status_code == 200
checks.append(CheckResult("Gateway health", ok, f"status={health.status_code}"))
if not ok:
return checks, artifacts
except Exception as exc:
checks.append(CheckResult("Gateway health", False, f"exception={exc}"))
return checks, artifacts
try:
registry = await client.get("/registry")
data = registry.json() if registry.status_code == 200 else {}
services = data.get("services", {})
missing = [name for name in REQUIRED_SERVICES if name not in services]
ok = registry.status_code == 200 and not missing
details = f"status={registry.status_code}"
if missing:
details += f", missing={missing}"
checks.append(CheckResult("Service registry", ok, details))
if not ok:
return checks, artifacts
except Exception as exc:
checks.append(CheckResult("Service registry", False, f"exception={exc}"))
return checks, artifacts
try:
login = await client.post("/proxy/auth/auth/login", json={"username": "admin", "password": "admin123"})
ok = login.status_code == 200 and "access_token" in login.json()
checks.append(CheckResult("Auth login", ok, f"status={login.status_code}"))
except Exception as exc:
checks.append(CheckResult("Auth login", False, f"exception={exc}"))
return checks, artifacts
try:
queue = await client.post(
"/proxy/routing/queues",
headers=admin,
json={
"name": f"UAT Queue {now_compact()}",
"description": "UAT preflight queue",
"rules": [
{"channel": "voice", "priority": 3, "strategy": "round_robin", "sla_seconds": 30},
{"channel": "telegram", "priority": 3, "strategy": "round_robin", "sla_seconds": 45},
],
},
)
queue_ok = queue.status_code == 200
queue_id = queue.json().get("queue_id") if queue_ok else ""
artifacts["queue_id"] = queue_id
checks.append(CheckResult("Queue create", queue_ok, f"status={queue.status_code}, queue_id={queue_id}"))
except Exception as exc:
checks.append(CheckResult("Queue create", False, f"exception={exc}"))
return checks, artifacts
try:
customer = await client.post(
"/proxy/customer/customers",
json={
"display_name": "UAT Preflight Customer",
"phones": ["+77019990000"],
"preferred_phone": "+77019990000",
"tags": ["uat", "preflight"],
},
)
customer_ok = customer.status_code == 200
customer_id = customer.json().get("customer_id") if customer_ok else ""
artifacts["customer_id"] = customer_id
checks.append(
CheckResult("Customer create", customer_ok, f"status={customer.status_code}, customer_id={customer_id}")
)
except Exception as exc:
checks.append(CheckResult("Customer create", False, f"exception={exc}"))
return checks, artifacts
interaction_id = ""
try:
interaction = await client.post(
"/proxy/interaction/interactions",
headers=operator,
json={
"channel": "voice",
"subject": "UAT preflight voice interaction",
"customer_id": artifacts["customer_id"],
"queue_id": artifacts["queue_id"],
"priority": 3,
},
)
ok = interaction.status_code == 200
interaction_id = interaction.json().get("interaction_id") if ok else ""
artifacts["voice_interaction_id"] = interaction_id
checks.append(CheckResult("Voice interaction create", ok, f"status={interaction.status_code}"))
if not ok:
return checks, artifacts
assign = await client.patch(
f"/proxy/interaction/interactions/{interaction_id}/assign",
headers=supervisor,
json={"assignee": "operator_a"},
)
assign_ok = assign.status_code == 200 and assign.json().get("status") == "in_progress"
checks.append(CheckResult("Voice interaction assign", assign_ok, f"status={assign.status_code}"))
close = await client.patch(
f"/proxy/interaction/interactions/{interaction_id}/status",
headers=operator,
json={"status": "closed"},
)
close_ok = close.status_code == 200 and close.json().get("status") == "closed"
checks.append(CheckResult("Voice interaction close", close_ok, f"status={close.status_code}"))
except Exception as exc:
checks.append(CheckResult("Voice interaction lifecycle", False, f"exception={exc}"))
return checks, artifacts
try:
tg = await client.post(
"/proxy/telegram/integrations/telegram/webhook",
json={
"chat_id": f"uat_chat_{now_compact()}",
"text": "UAT preflight telegram",
"customer_external_id": None,
"payload": {"source": "uat_preflight"},
},
)
ok = tg.status_code == 200 and tg.json().get("message_id")
artifacts["telegram_message_id"] = tg.json().get("message_id") if tg.status_code == 200 else ""
checks.append(CheckResult("Telegram webhook", bool(ok), f"status={tg.status_code}"))
except Exception as exc:
checks.append(CheckResult("Telegram webhook", False, f"exception={exc}"))
return checks, artifacts
try:
cat = await client.post(
"/proxy/kb/knowledge/categories",
headers=analyst,
json={"name": f"UAT {now_compact()}", "description": "UAT preflight"},
)
cat_ok = cat.status_code == 200
category_id = cat.json().get("category_id") if cat_ok else ""
if not cat_ok:
checks.append(CheckResult("KB category", False, f"status={cat.status_code}"))
return checks, artifacts
art = await client.post(
"/proxy/kb/knowledge/articles",
headers=analyst,
json={
"category_id": category_id,
"title": "UAT preflight article",
"body": "Use this article during UAT flow.",
"tags": ["uat", "kb"],
},
)
art_ok = art.status_code == 200
checks.append(CheckResult("KB article", art_ok, f"status={art.status_code}"))
if not art_ok:
return checks, artifacts
search = await client.get("/proxy/kb/knowledge/search?q=preflight")
search_ok = search.status_code == 200 and len(search.json()) > 0
checks.append(CheckResult("KB search", search_ok, f"status={search.status_code}, count={len(search.json())}"))
except Exception as exc:
checks.append(CheckResult("KB checks", False, f"exception={exc}"))
return checks, artifacts
try:
ingest = await client.post(
"/proxy/reporting/reports/events",
json={
"queue_id": artifacts["queue_id"],
"answered": True,
"wait_seconds": 18,
"handle_seconds": 105,
"abandoned": False,
"resolved_first_contact": True,
},
)
ingest_ok = ingest.status_code == 200
checks.append(CheckResult("KPI event ingest", ingest_ok, f"status={ingest.status_code}"))
if not ingest_ok:
return checks, artifacts
kpi = await client.get(f"/proxy/reporting/reports/kpi?queue_id={artifacts['queue_id']}")
kpi_ok = kpi.status_code == 200 and "kpi" in kpi.json()
checks.append(CheckResult("KPI report", kpi_ok, f"status={kpi.status_code}"))
except Exception as exc:
checks.append(CheckResult("KPI checks", False, f"exception={exc}"))
return checks, artifacts
try:
up1 = await client.post(
"/proxy/supervisor/supervisor/agent-states",
json={"agent_id": "uat_agent_1", "state": "READY", "queue_id": artifacts["queue_id"]},
)
up2 = await client.post(
"/proxy/supervisor/supervisor/agent-states",
json={"agent_id": "uat_agent_2", "state": "BUSY", "queue_id": artifacts["queue_id"]},
)
rt = await client.get("/proxy/supervisor/supervisor/realtime")
realtime_ok = up1.status_code == 200 and up2.status_code == 200 and rt.status_code == 200
checks.append(CheckResult("Supervisor realtime", realtime_ok, f"status={rt.status_code}"))
except Exception as exc:
checks.append(CheckResult("Supervisor realtime", False, f"exception={exc}"))
return checks, artifacts
try:
audit = await client.get("/proxy/audit/audit/events?limit=5")
ok = audit.status_code == 200
checks.append(CheckResult("Audit availability", ok, f"status={audit.status_code}"))
except Exception as exc:
checks.append(CheckResult("Audit availability", False, f"exception={exc}"))
artifacts["completed_at"] = iso_now()
return checks, artifacts
def write_report(
output_path: Path,
base_url: str,
checks: list[CheckResult],
artifacts: dict[str, Any],
auto_start: bool,
) -> None:
ok_count = sum(1 for c in checks if c.ok)
total = len(checks)
overall = "PASS" if ok_count == total else "FAIL"
lines: list[str] = []
lines.append("# UAT Preflight Report")
lines.append("")
lines.append(f"- Timestamp (UTC): {iso_now()}")
lines.append(f"- Base URL: {base_url}")
lines.append(f"- Auto-start mode: {str(auto_start).lower()}")
lines.append(f"- Overall: {overall} ({ok_count}/{total})")
lines.append("")
lines.append("## Scope Baseline")
lines.append("- Pilot scope freeze: `docs/gates/mvp-pilot-baseline.md`")
lines.append("- Deferred scope backlog: `docs/roadmap/05-wave2-backlog.md`")
lines.append("")
lines.append("## Checks")
for item in checks:
marker = "PASS" if item.ok else "FAIL"
lines.append(f"- [{marker}] {item.name}: {item.details}")
lines.append("")
lines.append("## Artifacts")
if artifacts:
for key, value in artifacts.items():
lines.append(f"- {key}: {value}")
else:
lines.append("- none")
lines.append("")
lines.append("## Next Action")
lines.append("- If all checks passed, run `scripts/uat_dry_run.py` and prepare the manual UAT session.")
lines.append("")
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text("\n".join(lines), encoding="utf-8")
async def main() -> None:
parser = argparse.ArgumentParser(description="UAT preflight checker for pilot session")
parser.add_argument("--base-url", default="http://localhost:8080", help="Gateway base URL")
parser.add_argument("--auto-start", action="store_true", help="Auto-start local services for preflight")
parser.add_argument(
"--output",
default="",
help="Report output path (default: docs/uat/evidence/preflight_<timestamp>.md)",
)
parser.add_argument("--keep-data", action="store_true", help="Keep temporary auto-start data directory")
args = parser.parse_args()
output = Path(args.output) if args.output else DEFAULT_EVIDENCE_DIR / f"preflight_{now_compact()}.md"
output = output if output.is_absolute() else (ROOT / output).resolve()
processes: list[subprocess.Popen] = []
run_data_dir = DATA_ROOT / f"run_{int(time.time() * 1000)}"
base_url = args.base_url
checks: list[CheckResult] = []
artifacts: dict[str, Any] = {}
try:
if args.auto_start:
processes = await start_services_sequential(run_data_dir)
base_url = "http://127.0.0.1:48080"
checks, artifacts = await run_checks(base_url)
write_report(output, base_url, checks, artifacts, args.auto_start)
ok = all(item.ok for item in checks) and len(checks) > 0
print(f"UAT preflight report: {output}")
print(f"Result: {'PASS' if ok else 'FAIL'}")
sys.exit(0 if ok else 1)
finally:
stop_services(processes)
if args.auto_start and not args.keep_data:
try:
if run_data_dir.exists():
shutil.rmtree(run_data_dir)
except Exception:
pass
if __name__ == "__main__":
asyncio.run(main())
-40
View File
@@ -1,40 +0,0 @@
from __future__ import annotations
import copy
import json
import os
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Any
def _data_dir() -> Path:
root = Path(os.getenv("CC_DATA_DIR", ".data")).resolve()
root.mkdir(parents=True, exist_ok=True)
return root
def _file_path(name: str) -> Path:
safe = name.replace("\\", "_").replace("/", "_")
return _data_dir() / safe
def load_json(name: str, default: Any) -> Any:
path = _file_path(name)
if not path.exists():
return copy.deepcopy(default)
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return copy.deepcopy(default)
def save_json(name: str, data: Any) -> None:
path = _file_path(name)
path.parent.mkdir(parents=True, exist_ok=True)
with NamedTemporaryFile("w", encoding="utf-8", delete=False, dir=str(path.parent)) as tmp:
json.dump(data, tmp, ensure_ascii=False, indent=2)
tmp_path = Path(tmp.name)
tmp_path.replace(path)
-1
View File
@@ -1 +0,0 @@
null
-26
View File
@@ -1,26 +0,0 @@
{
"call_id": "1775500989.2",
"session_id": "ais_a72486f7cc",
"voice_session_id": "avs_896c6077bf",
"status_label": "AI ответил клиенту",
"status_tone": "answered",
"customer_name_status": "name_not_obtained",
"customer_name_value": null,
"customer_name_source": "none",
"voice_start_language": "ru",
"customer_request_text": "Последняя реплика клиента недоступна.",
"ai_outcome_text": "Здравствуйте. Подскажите, пожалуйста, чем помочь.",
"handoff_reason": "",
"recommended_next_step": "Продолжайте разговор, учитывая уже собранный AI контекст.",
"generated_at": "2026-04-06T18:43:55+00:00",
"transcript_segments": [
{
"speaker": "assistant",
"text": "Здравствуйте. Подскажите, пожалуйста, чем помочь.",
"sequence_no": 1,
"source_type": "tts",
"created_at": "2026-04-06T18:43:22+00:00",
"interrupted": false
}
]
}
-28
View File
@@ -1,28 +0,0 @@
import urllib.request
import json
import traceback
try:
req = urllib.request.Request(
'http://92.38.48.166:8080/proxy/auth/auth/login',
data=json.dumps({'username': 'admin', 'password': 'admin123'}).encode('utf-8'),
headers={'Content-Type': 'application/json'}
)
token = json.loads(urllib.request.urlopen(req).read().decode()).get('access_token')
headers = {'Authorization': f'Bearer {token}'}
req = urllib.request.Request('http://92.38.48.166:8080/proxy/asterisk-bridge/asterisk/events?limit=250', headers=headers)
events = json.loads(urllib.request.urlopen(req).read().decode())
cids = []
for e in events:
if e['call_id'] not in cids: cids.append(e['call_id'])
for cid in cids[:1]:
summ_req = urllib.request.Request(f'http://92.38.48.166:8080/proxy/asterisk-bridge/asterisk/live-calls/{cid}/ai-summary', headers=headers)
summ = json.loads(urllib.request.urlopen(summ_req).read().decode())
print('Call ID:', cid)
print('Voice Session ID:', summ.get('voice_session_id'))
for t in summ.get('interaction_transcript', []):
print(f"[{t.get('sequence_no', '')}] {t['speaker']}: {t['text']}")
except Exception as e:
traceback.print_exc()
-103
View File
@@ -1,103 +0,0 @@
from pathlib import Path
from scripts.finalize_mvp_pilot import apply_gate4_closure, parse_open_defect_counts
from scripts.uat_manual_prepare import render_session_protocol
def test_render_session_protocol_prefills_safe_fields():
template = "\n".join(
[
"# UAT Session Protocol",
"",
"- Session ID:",
"- Date:",
"- Environment URL:",
"- Build/version:",
"- Deployment date:",
"- Cluster/namespace:",
"",
"- Operators:",
"- Supervisor:",
"- Analyst:",
"- Admin:",
"- Business owner:",
"- IT owner:",
"",
"- [ ] Preflight report attached (`docs/uat/evidence/preflight_*.md`)",
"- [ ] Defect log prepared from `docs/uat/defect-log-template.csv`",
]
)
result = render_session_protocol(
template,
session_id="UAT-MANUAL-001",
environment_url="http://pilot:8080",
build_version="v1.0.0-mvp",
deployment_date="2026-02-27",
cluster_namespace="pilot/ns",
participants={
"operators": "Ops Team",
"supervisor": "Sup User",
"analyst": "Analyst User",
"admin": "Admin User",
"business_owner": "Biz Owner",
"it_owner": "IT Owner",
},
preflight_name="preflight_latest.md",
defect_log_name="defect-log.csv",
)
assert "- Session ID: UAT-MANUAL-001" in result
assert "- Environment URL: http://pilot:8080" in result
assert "- Build/version: v1.0.0-mvp" in result
assert "- Business owner: Biz Owner" in result
assert "- [x] Preflight report attached (`attachments/preflight_latest.md`)" in result
assert "- [x] Defect log prepared from `defect-log.csv`" in result
def test_parse_open_defect_counts_counts_only_non_closed(tmp_path: Path):
defect_log = tmp_path / "defect-log.csv"
defect_log.write_text(
"\n".join(
[
"defect_id,severity,status,scenario,summary",
"UAT-001,P1,Open,S1,broken login",
"UAT-002,P1,Closed,S1,closed login",
"UAT-003,P2,Resolved,S2,resolved issue",
"UAT-004,P2,Open,S2,open issue",
"UAT-005,P3,Deferred,S3,deferred issue",
"UAT-006,P4,Open,S4,cosmetic issue",
]
),
encoding="utf-8",
)
counts = parse_open_defect_counts(defect_log)
assert counts == {"P1": 1, "P2": 1, "P3": 1, "P4": 1}
def test_apply_gate4_closure_marks_manual_items():
source = "\n".join(
[
"# Gate 4 Checklist (Pilot Hardening)",
"",
"- [ ] UAT signed with real operators/supervisors",
"- [ ] P1/P2 defects closed",
"",
"Notes:",
"- placeholder",
]
)
updated = apply_gate4_closure(
source,
session_id="UAT-MANUAL-001",
session_dir=Path("docs/uat/evidence/manual_UAT-MANUAL-001"),
acceptance="accepted_for_pilot_completion",
open_counts={"P1": 0, "P2": 0, "P3": 2, "P4": 1},
)
assert "- [x] UAT signed with real operators/supervisors" in updated
assert "- [x] P1/P2 defects closed" in updated
assert "Manual closure update:" in updated
assert "P1=0, P2=0, P3=2, P4=1" in updated
-781
View File
@@ -13,27 +13,6 @@ const state = {
selectedArticleId: '',
lastQuery: '',
},
analytics: {
preset: '7d',
fromTs: '',
toTs: '',
queueId: 'all',
channel: 'all',
trendMetric: 'volume',
overview: null,
compare: null,
trend: [],
channelRows: [],
queueRows: [],
coverage: null,
queueOptions: [],
realtime: null,
liveCallsCount: 0,
loading: false,
error: '',
queueAccessError: '',
requestId: 0,
},
oidc: {
enabled: false,
loginPath: '/auth/oidc/start?return_mode=popup',
@@ -50,26 +29,6 @@ const ROLE_LABELS = {
analyst: 'Аналитик',
};
const ANALYTICS_CHANNEL_OPTIONS = [
{ value: 'all', label: 'Все каналы' },
{ value: 'voice', label: 'Голос' },
{ value: 'telegram', label: 'Telegram' },
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'webchat', label: 'Веб-чат' },
{ value: 'email', label: 'Электронная почта' },
];
const ANALYTICS_METRIC_META = {
total: { unit: 'count', better: 'up', note: 'Всего обращений в окне' },
answered: { unit: 'count', better: 'up', note: 'Обработано командой' },
SL: { unit: 'pct', better: 'up', note: 'Доля в SLA' },
ASA: { unit: 'seconds', better: 'down', note: 'Среднее ожидание' },
AHT: { unit: 'seconds', better: 'down', note: 'Среднее время обработки' },
Abandon: { unit: 'pct', better: 'down', note: 'Потерянные обращения' },
FCR: { unit: 'pct', better: 'up', note: 'Решено с первого контакта' },
DigitalShare: { unit: 'pct', better: 'neutral', note: 'Доля цифровых каналов' },
volume: { unit: 'count', better: 'up', note: 'Объём по временной точке' },
};
function destinationForRole(role) {
if (role === 'admin') {
@@ -381,742 +340,6 @@ function renderKpi(data) {
$('kpiOutput').textContent = JSON.stringify(data, null, 2);
}
function emptyKpiEnvelope(fromTs = null, toTs = null) {
return {
window: { from: fromTs, to: toTs },
volume: {
total: 0,
answered: 0,
abandoned: 0,
},
kpi: {
SL: 0,
ASA: 0,
AHT: 0,
Abandon: 0,
FCR: 0,
AnswerRate: 0,
WaitP95: 0,
HandleP95: 0,
Occupancy: 0,
DigitalShare: 0,
},
breakdowns: {
by_channel: {},
},
filters: {},
};
}
function pad2(value) {
return String(value).padStart(2, '0');
}
function startOfDay(date) {
const next = new Date(date);
next.setHours(0, 0, 0, 0);
return next;
}
function addDays(date, days) {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function formatDateTimeLocalValue(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
}
function normalizeAnalyticsRange(from, to) {
const safeFrom = new Date(from);
const safeTo = new Date(to);
if (Number.isNaN(safeFrom.getTime()) || Number.isNaN(safeTo.getTime())) {
return { from: new Date(), to: new Date() };
}
if (safeTo <= safeFrom) {
return { from: safeFrom, to: new Date(safeFrom.getTime() + 60 * 60 * 1000) };
}
return { from: safeFrom, to: safeTo };
}
function analyticsRangeFromControls() {
const preset = $('analyticsPreset').value || state.analytics.preset || '7d';
const customFrom = $('analyticsFrom').value.trim();
const customTo = $('analyticsTo').value.trim();
const now = new Date();
let from;
let to;
let custom = false;
if (customFrom && customTo) {
custom = true;
from = new Date(customFrom);
to = new Date(customTo);
} else if (preset === 'today') {
from = startOfDay(now);
to = now;
} else if (preset === '30d') {
from = startOfDay(addDays(now, -29));
to = now;
} else {
from = startOfDay(addDays(now, -6));
to = now;
}
const current = normalizeAnalyticsRange(from, to);
const durationMs = Math.max(current.to.getTime() - current.from.getTime(), 60 * 60 * 1000);
const previous = {
from: new Date(current.from.getTime() - durationMs),
to: new Date(current.from.getTime()),
};
return {
preset,
custom,
current,
previous,
};
}
function syncAnalyticsStateFromControls() {
state.analytics.preset = $('analyticsPreset').value || '7d';
state.analytics.fromTs = $('analyticsFrom').value.trim();
state.analytics.toTs = $('analyticsTo').value.trim();
state.analytics.queueId = $('analyticsQueue').value || 'all';
state.analytics.channel = $('analyticsChannel').value || 'all';
state.analytics.trendMetric = $('analyticsTrendMetric').value || 'volume';
}
function syncAnalyticsControlsFromState() {
$('analyticsPreset').value = state.analytics.preset || '7d';
$('analyticsFrom').value = state.analytics.fromTs || '';
$('analyticsTo').value = state.analytics.toTs || '';
$('analyticsChannel').value = state.analytics.channel || 'all';
$('analyticsTrendMetric').value = state.analytics.trendMetric || 'volume';
renderAnalyticsQueueOptions();
}
function resetAnalyticsFilters() {
state.analytics.preset = '7d';
state.analytics.fromTs = '';
state.analytics.toTs = '';
state.analytics.queueId = 'all';
state.analytics.channel = 'all';
state.analytics.trendMetric = 'volume';
syncAnalyticsControlsFromState();
}
function analyticsQuery(range, overrides = {}) {
const params = new URLSearchParams();
params.set('from_ts', range.from.toISOString());
params.set('to_ts', range.to.toISOString());
const queueId = overrides.queueId ?? state.analytics.queueId;
const channel = overrides.channel ?? state.analytics.channel;
if (queueId && queueId !== 'all') {
params.set('queue_id', queueId);
}
if (channel && channel !== 'all') {
params.set('channel', channel);
}
return params.toString();
}
async function fetchAnalyticsKpi(range, overrides = {}) {
return api('reporting', `reports/kpi?${analyticsQuery(range, overrides)}`);
}
function renderAnalyticsQueueOptions() {
const select = $('analyticsQueue');
if (!select) {
return;
}
const selected = state.analytics.queueId || 'all';
const options = ['<option value="all">Все очереди</option>'];
if (selected !== 'all' && !state.analytics.queueOptions.some((item) => item.queue_id === selected)) {
options.push(`<option value="${escapeHtml(selected)}">${escapeHtml(selected)}</option>`);
}
options.push(
...state.analytics.queueOptions.map(
(item) => `<option value="${escapeHtml(item.queue_id)}">${escapeHtml(item.name || item.queue_id)}</option>`,
),
);
select.innerHTML = options.join('');
select.value = options.some((item) => item.includes(`value="${selected}"`)) ? selected : 'all';
}
async function loadAnalyticsQueueOptions() {
try {
const data = await api('routing', 'queues');
state.analytics.queueOptions = Array.isArray(data) ? data : [];
state.analytics.queueAccessError = '';
} catch (err) {
state.analytics.queueOptions = [];
state.analytics.queueAccessError = err.message;
}
renderAnalyticsQueueOptions();
return state.analytics.queueOptions;
}
function formatAnalyticsNumber(value, digits = 0) {
return new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(Number(value || 0));
}
function analyticsChannelLabel(channel) {
const meta = ANALYTICS_CHANNEL_OPTIONS.find((item) => item.value === channel);
return meta ? meta.label : String(channel || 'неизвестно');
}
function metricValueFromPayload(metric, payload) {
if (metric === 'volume' || metric === 'total') {
return Number(payload?.volume?.total || 0);
}
if (metric === 'answered') {
return Number(payload?.volume?.answered || 0);
}
return Number(payload?.kpi?.[metric] || 0);
}
function formatAnalyticsMetric(metric, value) {
const meta = ANALYTICS_METRIC_META[metric] || { unit: 'count' };
if (meta.unit === 'pct') {
return `${formatAnalyticsNumber(value, 2)}%`;
}
if (meta.unit === 'seconds') {
return `${formatAnalyticsNumber(value, 2)} с`;
}
return formatAnalyticsNumber(value, 0);
}
function signedDelta(value, digits = 1) {
if (!value) {
return '0';
}
const sign = value > 0 ? '+' : '-';
return `${sign}${formatAnalyticsNumber(Math.abs(value), digits)}`;
}
function analyticsDelta(metric, currentPayload, previousPayload) {
const current = metricValueFromPayload(metric, currentPayload);
const previous = metricValueFromPayload(metric, previousPayload);
const diff = current - previous;
const meta = ANALYTICS_METRIC_META[metric] || { unit: 'count', better: 'neutral' };
let text = signedDelta(diff, meta.unit === 'count' ? 0 : 1);
if (meta.unit === 'pct') {
text = `${text} п.п.`;
} else if (meta.unit === 'seconds') {
text = `${text} с`;
}
let tone = 'neutral';
if (meta.better === 'up') {
tone = diff > 0 ? 'positive' : diff < 0 ? 'negative' : 'neutral';
} else if (meta.better === 'down') {
tone = diff < 0 ? 'positive' : diff > 0 ? 'negative' : 'neutral';
}
return { text, tone };
}
function renderAnalyticsCard(label, metric, payload, previousPayload) {
const value = metricValueFromPayload(metric, payload);
const delta = analyticsDelta(metric, payload, previousPayload);
const note = ANALYTICS_METRIC_META[metric]?.note || '';
return `
<div class="summary-card analytics-card">
<div class="analytics-card-top">
<div class="summary-label">${label}</div>
<span class="analytics-delta analytics-delta-${delta.tone}">${delta.text}</span>
</div>
<div class="summary-value">${formatAnalyticsMetric(metric, value)}</div>
${note ? `<div class="summary-note">${note}</div>` : ''}
</div>
`;
}
function updateAnalyticsRangeHint(range) {
const filters = [];
if (state.analytics.queueId !== 'all') {
filters.push(`очередь: ${state.analytics.queueId}`);
}
if (state.analytics.channel !== 'all') {
filters.push(`канал: ${state.analytics.channel}`);
}
const suffix = filters.length ? ` | ${filters.join(' | ')}` : '';
$('analyticsRangeHint').textContent = range.custom
? `Кастомный диапазон: ${formatTime(range.current.from.toISOString())} - ${formatTime(range.current.to.toISOString())}${suffix}`
: `Период: ${range.preset}${suffix}`;
}
function renderAnalyticsOperationsStrip() {
const realtime = state.analytics.realtime || {};
const agents = realtime?.agents || {};
const byState = agents.by_state || {};
const firstQueue = Array.isArray(realtime?.queues) ? realtime.queues[0] || {} : {};
$('analyticsOperations').innerHTML = [
renderSummaryCard('Агенты', Number(agents.total || 0), 'Активный состав в оперативном срезе'),
renderSummaryCard('Готовы', Number(byState.READY || 0), 'Свободные агенты'),
renderSummaryCard('Заняты', Number(byState.BUSY || 0), 'В обработке'),
renderSummaryCard('Живые звонки', Number(state.analytics.liveCallsCount || 0), 'Последние звонки от Asterisk'),
renderSummaryCard('Очередь', firstQueue.queue_id || 'нет', `В очереди: ${Number(firstQueue.in_queue || 0)}`),
].join('');
}
function renderAnalyticsOverview() {
const current = state.analytics.overview || emptyKpiEnvelope();
const previous = state.analytics.compare || emptyKpiEnvelope();
$('analyticsOverview').innerHTML = [
renderAnalyticsCard('Поток', 'total', current, previous),
renderAnalyticsCard('Обработано', 'answered', current, previous),
renderAnalyticsCard('SL', 'SL', current, previous),
renderAnalyticsCard('ASA', 'ASA', current, previous),
renderAnalyticsCard('AHT', 'AHT', current, previous),
renderAnalyticsCard('Потери', 'Abandon', current, previous),
renderAnalyticsCard('FCR', 'FCR', current, previous),
renderAnalyticsCard('Цифровая доля', 'DigitalShare', current, previous),
].join('');
}
function renderAnalyticsChannelTable() {
const container = $('analyticsChannelTable');
const rows = state.analytics.channelRows || [];
if (!rows.length) {
container.innerHTML = '<div class="empty-state">Нет данных по каналам за выбранный период.</div>';
return;
}
container.innerHTML = `
<div class="analytics-table-head analytics-channel-grid">
<span>Канал</span>
<span>Всего</span>
<span>Обработано</span>
<span>Потеряно</span>
<span>Доля ответа</span>
</div>
${rows
.map((item) => {
const answerRate = item.total ? (item.answered / item.total) * 100 : 0;
return `
<div class="analytics-table-row analytics-channel-grid">
<span>${escapeHtml(analyticsChannelLabel(item.channel))}</span>
<span>${formatAnalyticsNumber(item.total, 0)}</span>
<span>${formatAnalyticsNumber(item.answered, 0)}</span>
<span>${formatAnalyticsNumber(item.abandoned, 0)}</span>
<span>${formatAnalyticsNumber(answerRate, 2)}%</span>
</div>
`;
})
.join('')}
`;
}
function renderAnalyticsQueueTable() {
const container = $('analyticsQueueTable');
const rows = state.analytics.queueRows || [];
if (!rows.length) {
const reason = state.analytics.queueAccessError
? 'Таблица очередей недоступна для текущей роли: backend не разрешил получить каталог очередей.'
: 'Очереди пока не вернули исторические данные за выбранное окно.';
container.innerHTML = `<div class="empty-state">${escapeHtml(reason)}</div>`;
return;
}
container.innerHTML = `
<div class="analytics-table-head analytics-queue-grid">
<span>Очередь</span>
<span>Всего</span>
<span>Обработано</span>
<span>SL</span>
<span>Потери</span>
</div>
${rows
.map((item) => `
<button class="analytics-table-row analytics-table-button analytics-queue-grid${item.queue_id === state.analytics.queueId ? ' selected' : ''}" type="button" data-analytics-queue-id="${escapeHtml(item.queue_id)}">
<span><strong>${escapeHtml(item.name || item.queue_id)}</strong><small>${escapeHtml(item.queue_id)}</small></span>
<span>${formatAnalyticsNumber(item.total, 0)}</span>
<span>${formatAnalyticsNumber(item.answered, 0)}</span>
<span>${formatAnalyticsNumber(item.SL, 2)}%</span>
<span>${formatAnalyticsNumber(item.Abandon, 2)}%</span>
</button>
`)
.join('')}
`;
}
function renderAnalyticsCoverage() {
const implemented = Array.isArray(state.analytics.coverage?.implemented_metrics)
? state.analytics.coverage.implemented_metrics
: [];
const dimensions = Array.isArray(state.analytics.coverage?.dimensions)
? state.analytics.coverage.dimensions
: [];
const filters = Array.isArray(state.analytics.coverage?.supported_filters)
? state.analytics.coverage.supported_filters
: [];
$('analyticsCoverage').innerHTML = `
<div class="analytics-coverage-list">
<div class="analytics-coverage-block">
<h4>Что уже есть</h4>
<div class="analytics-coverage-chips">
${implemented.length
? implemented
.map((item) => `<span class="analytics-coverage-chip">${escapeHtml(item.code)} · ${escapeHtml(item.label)}</span>`)
.join('')
: '<span class="empty-state">Сервис coverage пока не вернул каталог метрик.</span>'}
</div>
</div>
<div class="analytics-coverage-block">
<h4>Разрезы</h4>
<div class="analytics-coverage-chips">
${dimensions.length
? dimensions.map((item) => `<span class="analytics-coverage-chip soft">${escapeHtml(item)}</span>`).join('')
: '<span class="empty-state">Нет данных о разрезах.</span>'}
</div>
</div>
<div class="analytics-coverage-block">
<h4>Фильтры</h4>
<div class="analytics-coverage-chips">
${filters.length
? filters.map((item) => `<span class="analytics-coverage-chip soft">${escapeHtml(item)}</span>`).join('')
: '<span class="empty-state">Нет данных о фильтрах.</span>'}
</div>
</div>
</div>
<div class="analytics-placeholder-grid">
<article class="analytics-placeholder-card">
<strong>Аналитика по агентам</strong>
<p>Появится, когда reporting начнёт отдавать детализацию по <code>agent_id</code> и персональные ряды производительности.</p>
</article>
<article class="analytics-placeholder-card">
<strong>AI-аналитика</strong>
<p>Ждём отдельные AI-метрики: долю закрытий, передачу оператору, задержку ответа и покрытие по AI-сводкам.</p>
</article>
<article class="analytics-placeholder-card">
<strong>Качество речи</strong>
<p>Блок будет строиться поверх скоринга транскриптов, QA-правил и расширенных метаданных записей.</p>
</article>
${
state.analytics.queueAccessError
? `<article class="analytics-placeholder-card">
<strong>Доступ к каталогу очередей</strong>
<p>${escapeHtml(state.analytics.queueAccessError)}</p>
</article>`
: ''
}
</div>
`;
}
function buildTrendBuckets(range, preset, custom) {
const buckets = [];
const hourMs = 60 * 60 * 1000;
const dayMs = 24 * hourMs;
if (custom) {
const fromDay = startOfDay(range.from);
const endDay = startOfDay(addDays(range.to, 1));
const totalDays = Math.max(1, Math.ceil((endDay.getTime() - fromDay.getTime()) / dayMs));
const bucketDays = Math.max(1, Math.ceil(totalDays / 31));
for (let cursor = new Date(fromDay); cursor < endDay; cursor = new Date(cursor.getTime() + bucketDays * dayMs)) {
const bucketFrom = new Date(cursor);
const bucketTo = new Date(Math.min(bucketFrom.getTime() + bucketDays * dayMs, endDay.getTime()));
const lastDay = new Date(bucketTo.getTime() - dayMs);
const label = bucketDays === 1
? bucketFrom.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })
: `${bucketFrom.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })} - ${lastDay.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })}`;
buckets.push({ label, range: { from: bucketFrom, to: bucketTo } });
}
return buckets.slice(0, 31);
}
if (preset === 'today') {
const dayStart = startOfDay(range.from);
for (let hour = 0; hour < 24; hour += 1) {
const bucketFrom = new Date(dayStart.getTime() + hour * hourMs);
const bucketTo = new Date(bucketFrom.getTime() + hourMs);
buckets.push({
label: `${pad2(hour)}:00`,
range: { from: bucketFrom, to: bucketTo },
});
}
return buckets;
}
const days = preset === '30d' ? 30 : 7;
const firstDay = startOfDay(range.from);
for (let day = 0; day < days; day += 1) {
const bucketFrom = new Date(firstDay.getTime() + day * dayMs);
const bucketTo = new Date(bucketFrom.getTime() + dayMs);
buckets.push({
label: bucketFrom.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' }),
range: { from: bucketFrom, to: bucketTo },
});
}
return buckets;
}
async function loadAnalyticsTrend(rangeMeta, requestId) {
const buckets = buildTrendBuckets(rangeMeta.current, rangeMeta.preset, rangeMeta.custom);
const items = await Promise.all(
buckets.map(async (bucket) => {
try {
const payload = await fetchAnalyticsKpi(bucket.range);
return { ...bucket, payload };
} catch {
return { ...bucket, payload: emptyKpiEnvelope(bucket.range.from.toISOString(), bucket.range.to.toISOString()) };
}
}),
);
if (requestId !== state.analytics.requestId) {
return [];
}
return items;
}
async function loadAnalyticsQueueRows(rangeMeta, queues, requestId) {
if (!queues.length) {
return [];
}
const rows = await Promise.all(
queues.map(async (queue) => {
try {
const payload = await fetchAnalyticsKpi(rangeMeta.current, {
queueId: queue.queue_id,
channel: state.analytics.channel,
});
return {
queue_id: queue.queue_id,
name: queue.name || queue.queue_id,
total: Number(payload?.volume?.total || 0),
answered: Number(payload?.volume?.answered || 0),
SL: Number(payload?.kpi?.SL || 0),
Abandon: Number(payload?.kpi?.Abandon || 0),
};
} catch {
return {
queue_id: queue.queue_id,
name: queue.name || queue.queue_id,
total: 0,
answered: 0,
SL: 0,
Abandon: 0,
};
}
}),
);
if (requestId !== state.analytics.requestId) {
return [];
}
return rows.sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
}
function renderAnalyticsTrendChart() {
const metric = state.analytics.trendMetric || 'volume';
const items = state.analytics.trend || [];
const container = $('analyticsTrendChart');
if (!items.length) {
container.innerHTML = '<div class="empty-state">Нет данных тренда за выбранный период.</div>';
return;
}
const values = items.map((item) => metricValueFromPayload(metric, item.payload));
const maxValue = Math.max(...values, 1);
const width = 720;
const height = 220;
const paddingLeft = 38;
const paddingRight = 16;
const paddingTop = 16;
const paddingBottom = 36;
const chartWidth = width - paddingLeft - paddingRight;
const chartHeight = height - paddingTop - paddingBottom;
const step = items.length > 1 ? chartWidth / (items.length - 1) : chartWidth;
const pointFor = (value, index) => {
const x = paddingLeft + index * step;
const y = paddingTop + chartHeight - (value / maxValue) * chartHeight;
return { x, y };
};
const polyline = values
.map((value, index) => {
const point = pointFor(value, index);
return `${point.x},${point.y}`;
})
.join(' ');
const grid = [0, 0.25, 0.5, 0.75, 1]
.map((ratio) => {
const y = paddingTop + chartHeight - ratio * chartHeight;
const labelValue = maxValue * ratio;
return `
<line x1="${paddingLeft}" y1="${y}" x2="${width - paddingRight}" y2="${y}" class="analytics-grid-line"></line>
<text x="${paddingLeft - 8}" y="${y + 4}" class="analytics-axis-label" text-anchor="end">${escapeHtml(formatAnalyticsNumber(labelValue, metric === 'volume' ? 0 : 1))}</text>
`;
})
.join('');
const markers = values
.map((value, index) => {
const point = pointFor(value, index);
return `<circle cx="${point.x}" cy="${point.y}" r="4" class="analytics-point"></circle>`;
})
.join('');
const labelStep = items.length > 12 ? Math.ceil(items.length / 6) : 1;
const labels = items
.map((item, index) => {
if (index % labelStep !== 0 && index !== items.length - 1) {
return '';
}
const point = pointFor(values[index], index);
return `<text x="${point.x}" y="${height - 10}" class="analytics-axis-label" text-anchor="middle">${escapeHtml(item.label)}</text>`;
})
.join('');
container.innerHTML = `
<div class="analytics-chart-meta">
<strong>${escapeHtml(metric === 'volume' ? 'Volume' : metric)}</strong>
<span>${escapeHtml(formatAnalyticsMetric(metric, values[values.length - 1] || 0))} в последней точке</span>
</div>
<svg viewBox="0 0 ${width} ${height}" class="analytics-chart-svg" role="img" aria-label="Analytics trend chart">
${grid}
<polyline points="${polyline}" class="analytics-line"></polyline>
${markers}
${labels}
</svg>
`;
}
function renderAnalyticsEmptyState() {
const box = $('analyticsEmptyState');
if (state.analytics.error) {
box.hidden = false;
box.textContent = `Не удалось загрузить аналитику: ${state.analytics.error}`;
return;
}
const total = Number(state.analytics.overview?.volume?.total || 0);
if (!state.analytics.loading && total === 0) {
box.hidden = false;
box.textContent = 'За выбранный период данных пока нет. Фильтры сохранены, а заглушки остаются видимыми.';
return;
}
box.hidden = true;
box.textContent = '';
}
function renderAnalyticsDashboard(rangeMeta = analyticsRangeFromControls()) {
$('analyticsRefreshBtn').disabled = state.analytics.loading;
renderAnalyticsQueueOptions();
updateAnalyticsRangeHint(rangeMeta);
renderAnalyticsOperationsStrip();
renderAnalyticsOverview();
renderAnalyticsTrendChart();
renderAnalyticsChannelTable();
renderAnalyticsQueueTable();
renderAnalyticsCoverage();
renderAnalyticsEmptyState();
}
async function loadAnalyticsDashboard(silent = false) {
syncAnalyticsStateFromControls();
const rangeMeta = analyticsRangeFromControls();
const requestId = state.analytics.requestId + 1;
state.analytics.requestId = requestId;
state.analytics.loading = true;
state.analytics.error = '';
renderAnalyticsDashboard(rangeMeta);
try {
const queuePromise = loadAnalyticsQueueOptions();
const [current, previous, coverage, realtime, liveCalls, queues] = await Promise.all([
fetchAnalyticsKpi(rangeMeta.current),
fetchAnalyticsKpi(rangeMeta.previous).catch(() => emptyKpiEnvelope()),
api('reporting', 'reports/coverage').catch(() => ({ implemented_metrics: [], dimensions: [], supported_filters: [] })),
api('supervisor', 'supervisor/realtime').catch(() => ({ agents: { total: 0, by_state: {} }, queues: [] })),
api('supervisor', 'supervisor/live-calls?limit=20').catch(() => ({ items: [] })),
queuePromise,
]);
if (requestId !== state.analytics.requestId) {
return;
}
state.analytics.overview = current || emptyKpiEnvelope(rangeMeta.current.from.toISOString(), rangeMeta.current.to.toISOString());
state.analytics.compare = previous || emptyKpiEnvelope(rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString());
state.analytics.coverage = coverage || { implemented_metrics: [], dimensions: [], supported_filters: [] };
state.analytics.realtime = realtime || { agents: { total: 0, by_state: {} }, queues: [] };
state.analytics.liveCallsCount = Array.isArray(liveCalls?.items) ? liveCalls.items.length : 0;
const byChannel = current?.breakdowns?.by_channel || {};
state.analytics.channelRows = Object.entries(byChannel)
.map(([channel, payload]) => ({
channel,
total: Number(payload?.total || 0),
answered: Number(payload?.answered || 0),
abandoned: Number(payload?.abandoned || 0),
}))
.sort((a, b) => b.total - a.total || a.channel.localeCompare(b.channel));
const [trend, queueRows] = await Promise.all([
loadAnalyticsTrend(rangeMeta, requestId),
loadAnalyticsQueueRows(rangeMeta, queues, requestId),
]);
if (requestId !== state.analytics.requestId) {
return;
}
state.analytics.trend = trend;
state.analytics.queueRows = queueRows;
state.analytics.loading = false;
renderAnalyticsDashboard(rangeMeta);
if (!silent) {
log('Analytics dashboard updated', {
total: current?.volume?.total || 0,
queues: queueRows.length,
trend_points: trend.length,
});
}
} catch (err) {
if (requestId !== state.analytics.requestId) {
return;
}
state.analytics.loading = false;
state.analytics.error = err.message;
state.analytics.overview = emptyKpiEnvelope();
state.analytics.compare = emptyKpiEnvelope();
state.analytics.trend = [];
state.analytics.channelRows = [];
state.analytics.queueRows = [];
renderAnalyticsDashboard(rangeMeta);
if (!silent) {
log('Не удалось обновить аналитическую витрину', { error: err.message });
}
}
}
function handleAnalyticsQueueClick(event) {
const button = event.target.closest('[data-analytics-queue-id]');
if (!button) {
return;
}
const queueId = button.dataset.analyticsQueueId || 'all';
state.analytics.queueId = queueId;
$('analyticsQueue').value = queueId;
$('queueMetricId').value = queueId;
void loadAnalyticsDashboard();
}
function formatTime(ts) {
if (!ts) {
return '-';
@@ -1290,11 +513,7 @@ async function loadKnowledgeCategories(silent = false) {
async function loadKnowledgeArticles(silent = false) {
const query = $('kbSearchQuery').value.trim();
const language = $('kbSearchLanguage') ? $('kbSearchLanguage').value.trim() : '';
const params = new URLSearchParams({ q: query, limit: '100' });
if (language) {
params.set('language', language);
}
const data = await api('kb', `knowledge/search?${params.toString()}`);
state.knowledge.articles = Array.isArray(data) ? data : [];
state.knowledge.lastQuery = query;