chore: remove dead supervisor analytics and archived tooling
This commit is contained in:
Binary file not shown.
-1519
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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 +0,0 @@
|
||||
null
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user