diff --git a/.tmp_diff.txt b/.tmp_diff.txt deleted file mode 100644 index cbbefd1..0000000 Binary files a/.tmp_diff.txt and /dev/null differ diff --git a/check_pipeline.py b/check_pipeline.py deleted file mode 100644 index e69de29..0000000 diff --git a/diff_utf8.txt b/diff_utf8.txt deleted file mode 100644 index acf935d..0000000 --- a/diff_utf8.txt +++ /dev/null @@ -1,1519 +0,0 @@ -diff --git a/.env.example b/.env.example -index 92e7d1e..1e99052 100644 ---- a/.env.example -+++ b/.env.example -@@ -36,6 +36,8 @@ AI_API_BASE= - AI_API_KEY= - AI_MODEL=stub-telegram-assistant - AI_TIMEOUT_SECONDS=20 -+AI_CUSTOMER_PERSONA_MODE=operator_humanlike -+AI_DISCLOSURE_MODE=hidden - AI_TELEGRAM_ENABLED=0 - AI_TELEGRAM_ALWAYS_REPLY=0 - AI_TELEGRAM_MAX_CONTEXT_MESSAGES=20 -@@ -48,6 +50,7 @@ AI_WHATSAPP_MAX_KB_RESULTS=3 - AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD=0.65 - AI_VOICE_RUNTIME_SERVICE_URL=http://localhost:8018 - AI_VOICE_ENABLED=0 -+AI_VOICE_POLICY_MODE=llm_guarded - AI_VOICE_QUEUE_CONFIG_JSON={} - AI_VOICE_ASR_PROVIDER=openai - AI_VOICE_TTS_PROVIDER=openai -diff --git a/.env.production.template b/.env.production.template -index b65f60d..7f32934 100644 ---- a/.env.production.template -+++ b/.env.production.template -@@ -32,6 +32,8 @@ AI_API_BASE=https:///v1 - AI_API_KEY= - AI_MODEL= - AI_TIMEOUT_SECONDS=20 -+AI_CUSTOMER_PERSONA_MODE=operator_humanlike -+AI_DISCLOSURE_MODE=hidden - AI_TELEGRAM_ENABLED=0 - AI_TELEGRAM_ALWAYS_REPLY=0 - AI_TELEGRAM_MAX_CONTEXT_MESSAGES=20 -@@ -43,6 +45,7 @@ AI_WHATSAPP_MAX_CONTEXT_MESSAGES=20 - AI_WHATSAPP_MAX_KB_RESULTS=3 - AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD=0.65 - AI_VOICE_ENABLED=0 -+AI_VOICE_POLICY_MODE=llm_guarded - AI_VOICE_QUEUE_CONFIG_JSON={"voice_lab_ai":{"mode":"ai_first","agent_profile":"voice_support","handoff_queue_code":"voice_lab","language":"ru"}} - AI_VOICE_ASR_PROVIDER=openai - AI_VOICE_TTS_PROVIDER=openai -diff --git a/docs/acceptance/.gitignore b/docs/acceptance/.gitignore -deleted file mode 100644 -index 12bd819..0000000 ---- a/docs/acceptance/.gitignore -+++ /dev/null -@@ -1,4 +0,0 @@ --track9/[0-9]*/ --track9_1/[0-9]*/ --track10/[0-9]*/ --track11/[0-9]*/ -diff --git a/docs/acceptance/track10/README.md b/docs/acceptance/track10/README.md -deleted file mode 100644 -index b6e8840..0000000 ---- a/docs/acceptance/track10/README.md -+++ /dev/null -@@ -1,13 +0,0 @@ --# Track 10 Acceptance Evidence -- --Store Track 10 reliability evidence per run: -- --- `docs/acceptance/track10//...` -- --Recommended contents: --- `voice-latency-latest.json` --- `command-output.txt` --- `track10-acceptance.md` -- --Reference runbook: --- `docs/runbooks/track10-live-voice-reliability.md` -diff --git a/docs/acceptance/track10/track10-acceptance.md b/docs/acceptance/track10/track10-acceptance.md -deleted file mode 100644 -index 396a89f..0000000 ---- a/docs/acceptance/track10/track10-acceptance.md -+++ /dev/null -@@ -1,41 +0,0 @@ --# Track 10 Acceptance (Latest) -- --## Environment -- --- Date: `2026-03-05` --- Gateway URL: `http://127.0.0.1:8080` --- Database URL (masked): `sqlite:///.data_local/mvp_cc.db` --- Evidence folder: `docs/acceptance/track10/20260305_163330` -- --## Validation command -- --```powershell --python scripts\track10_voice_latency_report.py ` -- --database-url sqlite:///.data_local/mvp_cc.db ` -- --since-minutes 90 ` -- --breach-mode direct ` -- --json-out docs/acceptance/track10/20260305_163330/voice-latency-latest.json --``` -- --## Result summary -- --- total calls: `10` --- active_no_end: `0` --- ended_no_recording_upload: `0` --- ended_direct_calls: `4` --- ended_reconciled_calls: `6` --- p95 started->ended: `1686.35s` --- p95 ended->recording upload: `455.25s` --- p95 direct started->ended: `33.0s` --- p95 direct ended->recording upload: `2.85s` -- --## Decision -- --- [x] GO (with exception) --- [ ] NO-GO -- --## Notes -- --- Direct path remains stable (`active_no_end=0`, `ended_no_recording_upload=0`). --- Threshold exception recorded: direct `started->ended` is `33s` vs target `30s`. --- Detailed run: `docs/acceptance/track10/20260305_163330/track10-acceptance.md` -diff --git a/docs/acceptance/track11/README.md b/docs/acceptance/track11/README.md -deleted file mode 100644 -index 0862f76..0000000 ---- a/docs/acceptance/track11/README.md -+++ /dev/null -@@ -1,15 +0,0 @@ --# Track 11 Acceptance Evidence -- --Stable summary: -- --- [track11-acceptance.md](/e:/Zhan/docs/acceptance/track11/track11-acceptance.md) -- --Store Track 11 QA evidence per run: -- --- `docs/acceptance/track11//track11-acceptance.md` --- `docs/acceptance/track11//operator-live-calls.json` --- `docs/acceptance/track11//bridge-actions.json` --- `docs/acceptance/track11//playback-proof.json` -- --Reference runbook: --- `docs/runbooks/track11-live-operator-call-control.md` -diff --git a/docs/acceptance/track11/track11-acceptance.md b/docs/acceptance/track11/track11-acceptance.md -deleted file mode 100644 -index 7ad1401..0000000 ---- a/docs/acceptance/track11/track11-acceptance.md -+++ /dev/null -@@ -1,35 +0,0 @@ --# Track 11 Acceptance (Latest) -- --## Environment -- --- Date: `2026-03-07` --- Base URL: `http://localhost:8080` --- Database URL (masked): `sqlite:///.data_local/mvp_cc.db` --- Evidence package: [20260307_104748](/e:/Zhan/docs/acceptance/track11/20260307_104748) -- --## Executed scenario -- --1. Operator received a live inbound call in `/operator`. --2. Operator claimed the call. --3. Operator completed a real hangup path and recording playback was validated. --4. Operator completed a blind transfer flow from the live-call block. -- --## Validation summary -- --- live inbound visibility: `yes` --- claim persisted: `yes` --- hangup persisted: `yes` --- transfer persisted: `yes` --- `call.connected` present: `yes` --- `call.transferred` present: `yes` --- recording playback: `PASS` -- --## Result -- --- GO / NO-GO: `GO` --- Track 11 status: `Accepted` -- --## Notes -- --- Detailed package: `docs/acceptance/track11/20260307_104748/track11-acceptance.md` --- Track 11 keeps browser UI control and external softphone media as the accepted baseline. -diff --git a/docs/acceptance/track11/track11-acceptance.template.md b/docs/acceptance/track11/track11-acceptance.template.md -deleted file mode 100644 -index a44d5bc..0000000 ---- a/docs/acceptance/track11/track11-acceptance.template.md -+++ /dev/null -@@ -1,41 +0,0 @@ --# Track 11 Acceptance - -- --## Environment -- --- Base URL: `` --- Database: `` --- Asterisk VM: `` --- Operator account: `` --- Operator extension: `` -- --## Executed scenario -- --1. Caller `1001` called `7000`. --2. Operator opened `/operator` -> `╨Ц╨╕╨▓╤Л╨╡ ╨╖╨▓╨╛╨╜╨║╨╕`. --3. Operator clicked `╨Я╤А╨╕╨╜╤П╤В╤М ╨▓ ╤А╨░╨▒╨╛╤В╤Г`. --4. Voice conversation in `MicroSIP/Zoiper` was confirmed. --5. Operator executed one or both actions: -- - [ ] `╨Ч╨░╨▓╨╡╤А╤И╨╕╤В╤М` -- - [ ] `╨Я╨╡╤А╨╡╨┤╨░╤В╤М` (blind transfer) --6. Recording was checked in `/supervisor`. -- --## API and DB evidence -- --- Live call list saved: `operator-live-calls.json` --- Action log saved: `bridge-actions.json` --- Playback proof saved: `playback-proof.json` -- --## Mandatory checks -- --- [ ] `call.started` exists (`payload.source=asterisk`) --- [ ] `call.connected` exists --- [ ] `call.ended` exists --- [ ] `recording.ready` exists --- [ ] optional `call.transferred` exists when transfer path was tested --- [ ] no unresolved failed call-control actions in the acceptance window -- --## Decision -- --- GO / NO-GO: `` --- Notes: -- - `` -diff --git a/docs/acceptance/track12/20260307_161629/bridge-actions.json b/docs/acceptance/track12/20260307_161629/bridge-actions.json -deleted file mode 100644 -index 3289c98..0000000 ---- a/docs/acceptance/track12/20260307_161629/bridge-actions.json -+++ /dev/null -@@ -1,27 +0,0 @@ --{ -- "latest_call": "1772881979.108", -- "latest_call_actions": [], -- "reference_recent_ok_actions": [ -- { -- "call_id": "1772881673.105", -- "action_type": "hangup", -- "actor_user": "operator", -- "result_status": "ok", -- "created_at": "2026-03-07T11:08:42+00:00" -- }, -- { -- "call_id": "1772881673.105", -- "action_type": "claim", -- "actor_user": "operator", -- "result_status": "ok", -- "created_at": "2026-03-07T11:08:16+00:00" -- }, -- { -- "call_id": "1772876380.93", -- "action_type": "hangup", -- "actor_user": "operator", -- "result_status": "ok", -- "created_at": "2026-03-07T09:40:02+00:00" -- } -- ] --} -\ No newline at end of file -diff --git a/docs/acceptance/track12/20260307_161629/operator-live-calls-empty.json b/docs/acceptance/track12/20260307_161629/operator-live-calls-empty.json -deleted file mode 100644 -index 0637a08..0000000 ---- a/docs/acceptance/track12/20260307_161629/operator-live-calls-empty.json -+++ /dev/null -@@ -1 +0,0 @@ --[] -\ No newline at end of file -diff --git a/docs/acceptance/track12/20260307_161629/operator-live-calls.json b/docs/acceptance/track12/20260307_161629/operator-live-calls.json -deleted file mode 100644 -index 16517d8..0000000 ---- a/docs/acceptance/track12/20260307_161629/operator-live-calls.json -+++ /dev/null -@@ -1,25 +0,0 @@ --[ -- { -- "call_id": "1772881979.108", -- "caller_name": "1001", -- "caller_number": "1001", -- "channel_name": "PJSIP/2001-00000047", -- "claimed_at": null, -- "claimed_by_user": null, -- "connected_at": "2026-03-07T11:13:05+00:00", -- "ended_at": null, -- "hangup_cause": "unknown", -- "has_recording": false, -- "interaction_id": "int_0349796d1e", -- "last_transition_at": "2026-03-07T11:13:26+00:00", -- "operator_extension": "2001", -- "queue_code": "voice_lab", -- "queue_id": "que_23fb0ad07c", -- "started_at": "2026-03-07T11:13:01+00:00", -- "status": "active", -- "telephony_status": "connected", -- "terminal_action": null, -- "terminal_target": null, -- "updated_at": "2026-03-07T11:13:26+00:00" -- } --] -\ No newline at end of file -diff --git a/docs/acceptance/track12/20260307_161629/operator-recent-calls.json b/docs/acceptance/track12/20260307_161629/operator-recent-calls.json -deleted file mode 100644 -index 0637a08..0000000 ---- a/docs/acceptance/track12/20260307_161629/operator-recent-calls.json -+++ /dev/null -@@ -1 +0,0 @@ --[] -\ No newline at end of file -diff --git a/docs/acceptance/track12/20260307_161629/playback-proof.json b/docs/acceptance/track12/20260307_161629/playback-proof.json -deleted file mode 100644 -index 9d48bf9..0000000 ---- a/docs/acceptance/track12/20260307_161629/playback-proof.json -+++ /dev/null -@@ -1,10 +0,0 @@ --{ -- "call_id": "1772881979.108", -- "interaction_id": "int_0349796d1e", -- "recording_id": "rec_733ef323b1", -- "url": "http://127.0.0.1:8080/proxy/recording/recordings/rec_733ef323b1/content?disposition=inline", -- "status_code": 200, -- "content_type": "audio/wav", -- "content_length": 457324, -- "file_name": "20260307-161300-1772881979.108.wav" --} -\ No newline at end of file -diff --git a/docs/acceptance/track12/20260307_161629/track12-acceptance.md b/docs/acceptance/track12/20260307_161629/track12-acceptance.md -deleted file mode 100644 -index b52681b..0000000 ---- a/docs/acceptance/track12/20260307_161629/track12-acceptance.md -+++ /dev/null -@@ -1,75 +0,0 @@ --# Track 12 Acceptance - 20260307_161629 -- --## Environment -- --- Base URL: `http://localhost:8080` --- Database: `sqlite:///./.data_local/mvp_cc.db` --- Asterisk host: `92.38.48.166` --- Operator user: `operator` --- Operator extension: `2001` -- --## Acceptance Basis -- --Track 12 is a UX/state hardening layer on top of accepted Track 11. --This package closes the Track 12 delta and keeps Track 11 control-path proof as the inherited baseline. -- --Reference control-path package: --- [track11-acceptance.md](/e:/Zhan/docs/acceptance/track11/20260307_104748/track11-acceptance.md) -- --Track 12 delta call: --- `call_id = 1772881979.108` --- `interaction_id = int_0349796d1e` --- `recording_id = rec_733ef323b1` -- --## Executed Scenarios -- --- [x] Happy path inherited from accepted Track 11 package --- [x] Transfer path inherited from accepted Track 11 package --- [x] Passive path: answer in SIP, no browser action, call remains in `live-calls` until real hangup --- [x] No-fake-cards path: `╨Ц╨╕╨▓╤Л╨╡ ╨╖╨▓╨╛╨╜╨║╨╕` uses bridge data only and does not show `interaction:int_*` -- --## Delta Evidence -- --- Active live call snapshot: [operator-live-calls.json](/e:/Zhan/docs/acceptance/track12/20260307_161629/operator-live-calls.json) --- Recent call snapshot: [operator-recent-calls.json](/e:/Zhan/docs/acceptance/track12/20260307_161629/operator-recent-calls.json) --- Empty live snapshot after call end: [operator-live-calls-empty.json](/e:/Zhan/docs/acceptance/track12/20260307_161629/operator-live-calls-empty.json) --- Action log summary: [bridge-actions.json](/e:/Zhan/docs/acceptance/track12/20260307_161629/bridge-actions.json) --- Playback proof: [playback-proof.json](/e:/Zhan/docs/acceptance/track12/20260307_161629/playback-proof.json) --- Live monitor trace: [track12-monitor.log](/e:/Zhan/docs/acceptance/track12/20260307_161629/track12-monitor.log) -- --## What The Monitor Proved -- --For call `1772881979.108`: -- --1. At `2026-03-07T11:13:01+00:00` the call entered `/asterisk/live-calls` as: -- - `status = active` -- - `telephony_status = ringing` --2. At `2026-03-07T11:13:05+00:00` it stayed in `/asterisk/live-calls` and moved to: -- - `telephony_status = connected` -- - `operator_extension = 2001` --3. At `2026-03-07T11:13:26+00:00` a synthetic reconciled `call.ended` was emitted, but the call still remained in `/asterisk/live-calls`. --4. Only at the real final hangup (`2026-03-07T11:13:30+00:00`) the call left `/asterisk/live-calls` and moved to `/asterisk/recent-calls`. --5. `recording.ready` was emitted and the recording was imported successfully. -- --This closes the earlier defect where the card could disappear from the operator flow on the reconciled service event instead of the real final hangup. -- --## Mandatory Checks -- --- [x] `GET /proxy/asterisk-bridge/asterisk/live-calls` returns only active calls --- [x] `GET /proxy/asterisk-bridge/asterisk/recent-calls` returns only recent ended calls --- [x] passive SIP answer does not force the card out of `╨Р╨║╤В╨╕╨▓╨╜╤Л╨╡ ╨╖╨▓╨╛╨╜╨║╨╕` before real hangup --- [x] final hangup moves the card to `╨в╨╛╨╗╤М╨║╨╛ ╤З╤В╨╛ ╨╖╨░╨▓╨╡╤А╤И╤С╨╜╨╜╤Л╨╡` --- [x] no `interaction:int_*` fallback cards are used for `╨Ц╨╕╨▓╤Л╨╡ ╨╖╨▓╨╛╨╜╨║╨╕` --- [x] recording is available for the monitored call --- [x] Track 11 `claim / hangup / blind-transfer` control path remains covered by the accepted Track 11 package -- --## Notes -- --- The initial runtime regression after a local restart was caused by `scripts/local_stack.py` not loading `.env.production`; this is fixed and the launcher now auto-loads `.env.production`, `.env.local`, and `.env`. --- The current passive-path monitored call ended without browser actions, so `bridge-actions.json` contains no action rows for `1772881979.108`. This is expected for the passive-path check. --- Control-path actions (`claim`, `hangup`, `blind-transfer`) remain validated by the accepted Track 11 package and were not redefined by Track 12. -- --## Decision -- --- GO / NO-GO: `GO` --- Track 12 status: `Accepted` -diff --git a/docs/acceptance/track12/20260307_161629/track12-monitor.log b/docs/acceptance/track12/20260307_161629/track12-monitor.log -deleted file mode 100644 -index c364db0..0000000 ---- a/docs/acceptance/track12/20260307_161629/track12-monitor.log -+++ /dev/null -@@ -1,21 +0,0 @@ --[2026-03-07T16:10:31+05:00] monitor {"status": "started", "window_seconds": 180} --[2026-03-07T16:10:31+05:00] auth {"role": "operator", "status": "ok", "user": "operator"} --[2026-03-07T16:10:31+05:00] live-calls [] --[2026-03-07T16:10:31+05:00] recent-calls [{"call_id": "1772881673.105", "caller_name": "1001", "caller_number": "1001", "channel_name": "PJSIP/2001-00000045", "claimed_at": "2026-03-07T11:08:26+00:00", "claimed_by_user": "operator", "connected_at": "2026-03-07T11:07:57+00:00", "ended_at": "2026-03-07T11:08:53+00:00", "hangup_cause": "16", "has_recording": true, "interaction_id": "int_052586cdaa", "last_transition_at": "2026-03-07T11:08:53+00:00", "operator_extension": "2001", "queue_code": "voice_lab", "queue_id": "que_23fb0ad07c", "started_at": "2026-03-07T11:07:55+00:00", "status": "ended", "telephony_status": "ended", "terminal_action": "hangup", "terminal_target": null, "updated_at": "2026-03-07T11:08:53+00:00"}] --[2026-03-07T16:10:31+05:00] voice-events [{"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:53+00:00", "event_id": "vev_9245c8bb30", "event_type": "recording.ready", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": 58, "file_name": "20260307-160753-1772881673.105.wav", "linked_id": "1772881673.105", "mime_type": "audio/wav", "remote_path": "/var/spool/asterisk/monitor/mvpcc/20260307-160753-1772881673.105.wav", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:53+00:00", "event_id": "vev_3e2c4eedc7", "event_type": "call.ended", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": 58, "hangup_cause": "16", "linked_id": "1772881673.105", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:20+00:00", "event_id": "vev_dbf1ba4399", "event_type": "call.ended", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": null, "hangup_cause": "unknown", "linked_id": "1772881673.105", "reconciled": true, "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:07:57+00:00", "event_id": "vev_8be5ab34b9", "event_type": "call.connected", "interaction_id": "int_052586cdaa", "payload": {"channel_name": "PJSIP/2001-00000045", "claimed_by_user": null, "linked_id": "1772881673.105", "operator_extension": "2001", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:07:55+00:00", "event_id": "vev_2b41d093c7", "event_type": "call.started", "interaction_id": "int_052586cdaa", "payload": {"caller_name": "1001", "caller_number": "1001", "context": "from-softphones", "direction": "inbound", "extension": "7000", "linked_id": "1772881673.105", "source": "asterisk"}}, {"call_id": "1772876380.93", "created_at": "2026-03-07T09:40:12+00:00", "event_id": "vev_4c44dd5888", "event_type": "recording.ready", "interaction_id": "int_69443612e1", "payload": {"duration_seconds": 31, "file_name": "20260307-143940-1772876380.93.wav", "linked_id": "1772876380.93", "mime_type": "audio/wav", "remote_path": "/var/spool/asterisk/monitor/mvpcc/20260307-143940-1772876380.93.wav", "source": "asterisk"}}] --[2026-03-07T16:10:31+05:00] call-links [{"call_id": "1772881673.105", "claimed_by_user": "operator", "connected_at": "2026-03-07T11:07:57+00:00", "ended_at": "2026-03-07T11:08:53+00:00", "interaction_id": "int_052586cdaa", "operator_extension": "2001", "started_at": "2026-03-07T11:07:55+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772876380.93", "claimed_by_user": null, "connected_at": "2026-03-07T09:39:49+00:00", "ended_at": "2026-03-07T09:40:12+00:00", "interaction_id": "int_69443612e1", "operator_extension": "2001", "started_at": "2026-03-07T09:39:42+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772875171.90", "claimed_by_user": null, "connected_at": "2026-03-07T09:19:36+00:00", "ended_at": "2026-03-07T09:19:39+00:00", "interaction_id": "int_726c36d95d", "operator_extension": "2001", "started_at": "2026-03-07T09:19:33+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772874280.87", "claimed_by_user": null, "connected_at": "2026-03-07T09:04:47+00:00", "ended_at": "2026-03-07T09:04:54+00:00", "interaction_id": "int_6ef8abe6ec", "operator_extension": "2001", "started_at": "2026-03-07T09:04:42+00:00", "status": "ended", "telephony_status": "ended"}] --[2026-03-07T16:10:53+05:00] recent-calls [] --[2026-03-07T16:13:01+05:00] live-calls [{"call_id": "1772881979.108", "caller_name": "1001", "caller_number": "1001", "channel_name": "PJSIP/1001-00000046", "claimed_at": null, "claimed_by_user": null, "connected_at": null, "ended_at": null, "hangup_cause": null, "has_recording": false, "interaction_id": "int_0349796d1e", "last_transition_at": "2026-03-07T11:13:01+00:00", "operator_extension": null, "queue_code": "voice_lab", "queue_id": "que_23fb0ad07c", "started_at": "2026-03-07T11:13:01+00:00", "status": "active", "telephony_status": "ringing", "terminal_action": null, "terminal_target": null, "updated_at": "2026-03-07T11:13:01+00:00"}] --[2026-03-07T16:13:01+05:00] voice-events [{"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:01+00:00", "event_id": "vev_8ece03ec07", "event_type": "call.started", "interaction_id": "int_0349796d1e", "payload": {"caller_name": "1001", "caller_number": "1001", "context": "from-softphones", "direction": "inbound", "extension": "7000", "linked_id": "1772881979.108", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:53+00:00", "event_id": "vev_9245c8bb30", "event_type": "recording.ready", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": 58, "file_name": "20260307-160753-1772881673.105.wav", "linked_id": "1772881673.105", "mime_type": "audio/wav", "remote_path": "/var/spool/asterisk/monitor/mvpcc/20260307-160753-1772881673.105.wav", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:53+00:00", "event_id": "vev_3e2c4eedc7", "event_type": "call.ended", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": 58, "hangup_cause": "16", "linked_id": "1772881673.105", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:20+00:00", "event_id": "vev_dbf1ba4399", "event_type": "call.ended", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": null, "hangup_cause": "unknown", "linked_id": "1772881673.105", "reconciled": true, "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:07:57+00:00", "event_id": "vev_8be5ab34b9", "event_type": "call.connected", "interaction_id": "int_052586cdaa", "payload": {"channel_name": "PJSIP/2001-00000045", "claimed_by_user": null, "linked_id": "1772881673.105", "operator_extension": "2001", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:07:55+00:00", "event_id": "vev_2b41d093c7", "event_type": "call.started", "interaction_id": "int_052586cdaa", "payload": {"caller_name": "1001", "caller_number": "1001", "context": "from-softphones", "direction": "inbound", "extension": "7000", "linked_id": "1772881673.105", "source": "asterisk"}}] --[2026-03-07T16:13:01+05:00] call-links [{"call_id": "1772881979.108", "claimed_by_user": null, "connected_at": null, "ended_at": null, "interaction_id": "int_0349796d1e", "operator_extension": null, "started_at": "2026-03-07T11:13:01+00:00", "status": "active", "telephony_status": "ringing"}, {"call_id": "1772881673.105", "claimed_by_user": "operator", "connected_at": "2026-03-07T11:07:57+00:00", "ended_at": "2026-03-07T11:08:53+00:00", "interaction_id": "int_052586cdaa", "operator_extension": "2001", "started_at": "2026-03-07T11:07:55+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772876380.93", "claimed_by_user": null, "connected_at": "2026-03-07T09:39:49+00:00", "ended_at": "2026-03-07T09:40:12+00:00", "interaction_id": "int_69443612e1", "operator_extension": "2001", "started_at": "2026-03-07T09:39:42+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772875171.90", "claimed_by_user": null, "connected_at": "2026-03-07T09:19:36+00:00", "ended_at": "2026-03-07T09:19:39+00:00", "interaction_id": "int_726c36d95d", "operator_extension": "2001", "started_at": "2026-03-07T09:19:33+00:00", "status": "ended", "telephony_status": "ended"}] --[2026-03-07T16:13:06+05:00] live-calls [{"call_id": "1772881979.108", "caller_name": "1001", "caller_number": "1001", "channel_name": "PJSIP/2001-00000047", "claimed_at": null, "claimed_by_user": null, "connected_at": "2026-03-07T11:13:05+00:00", "ended_at": null, "hangup_cause": null, "has_recording": false, "interaction_id": "int_0349796d1e", "last_transition_at": "2026-03-07T11:13:05+00:00", "operator_extension": "2001", "queue_code": "voice_lab", "queue_id": "que_23fb0ad07c", "started_at": "2026-03-07T11:13:01+00:00", "status": "active", "telephony_status": "connected", "terminal_action": null, "terminal_target": null, "updated_at": "2026-03-07T11:13:05+00:00"}] --[2026-03-07T16:13:06+05:00] voice-events [{"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:05+00:00", "event_id": "vev_e3bec0b0e6", "event_type": "call.connected", "interaction_id": "int_0349796d1e", "payload": {"channel_name": "PJSIP/2001-00000047", "claimed_by_user": null, "linked_id": "1772881979.108", "operator_extension": "2001", "source": "asterisk"}}, {"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:01+00:00", "event_id": "vev_8ece03ec07", "event_type": "call.started", "interaction_id": "int_0349796d1e", "payload": {"caller_name": "1001", "caller_number": "1001", "context": "from-softphones", "direction": "inbound", "extension": "7000", "linked_id": "1772881979.108", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:53+00:00", "event_id": "vev_9245c8bb30", "event_type": "recording.ready", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": 58, "file_name": "20260307-160753-1772881673.105.wav", "linked_id": "1772881673.105", "mime_type": "audio/wav", "remote_path": "/var/spool/asterisk/monitor/mvpcc/20260307-160753-1772881673.105.wav", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:53+00:00", "event_id": "vev_3e2c4eedc7", "event_type": "call.ended", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": 58, "hangup_cause": "16", "linked_id": "1772881673.105", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:20+00:00", "event_id": "vev_dbf1ba4399", "event_type": "call.ended", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": null, "hangup_cause": "unknown", "linked_id": "1772881673.105", "reconciled": true, "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:07:57+00:00", "event_id": "vev_8be5ab34b9", "event_type": "call.connected", "interaction_id": "int_052586cdaa", "payload": {"channel_name": "PJSIP/2001-00000045", "claimed_by_user": null, "linked_id": "1772881673.105", "operator_extension": "2001", "source": "asterisk"}}] --[2026-03-07T16:13:06+05:00] call-links [{"call_id": "1772881979.108", "claimed_by_user": null, "connected_at": "2026-03-07T11:13:05+00:00", "ended_at": null, "interaction_id": "int_0349796d1e", "operator_extension": "2001", "started_at": "2026-03-07T11:13:01+00:00", "status": "active", "telephony_status": "connected"}, {"call_id": "1772881673.105", "claimed_by_user": "operator", "connected_at": "2026-03-07T11:07:57+00:00", "ended_at": "2026-03-07T11:08:53+00:00", "interaction_id": "int_052586cdaa", "operator_extension": "2001", "started_at": "2026-03-07T11:07:55+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772876380.93", "claimed_by_user": null, "connected_at": "2026-03-07T09:39:49+00:00", "ended_at": "2026-03-07T09:40:12+00:00", "interaction_id": "int_69443612e1", "operator_extension": "2001", "started_at": "2026-03-07T09:39:42+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772875171.90", "claimed_by_user": null, "connected_at": "2026-03-07T09:19:36+00:00", "ended_at": "2026-03-07T09:19:39+00:00", "interaction_id": "int_726c36d95d", "operator_extension": "2001", "started_at": "2026-03-07T09:19:33+00:00", "status": "ended", "telephony_status": "ended"}] --[2026-03-07T16:13:27+05:00] live-calls [{"call_id": "1772881979.108", "caller_name": "1001", "caller_number": "1001", "channel_name": "PJSIP/2001-00000047", "claimed_at": null, "claimed_by_user": null, "connected_at": "2026-03-07T11:13:05+00:00", "ended_at": null, "hangup_cause": "unknown", "has_recording": false, "interaction_id": "int_0349796d1e", "last_transition_at": "2026-03-07T11:13:26+00:00", "operator_extension": "2001", "queue_code": "voice_lab", "queue_id": "que_23fb0ad07c", "started_at": "2026-03-07T11:13:01+00:00", "status": "active", "telephony_status": "connected", "terminal_action": null, "terminal_target": null, "updated_at": "2026-03-07T11:13:26+00:00"}] --[2026-03-07T16:13:27+05:00] voice-events [{"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:26+00:00", "event_id": "vev_9a03fc9ff8", "event_type": "call.ended", "interaction_id": "int_0349796d1e", "payload": {"duration_seconds": null, "hangup_cause": "unknown", "linked_id": "1772881979.108", "reconciled": true, "source": "asterisk"}}, {"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:05+00:00", "event_id": "vev_e3bec0b0e6", "event_type": "call.connected", "interaction_id": "int_0349796d1e", "payload": {"channel_name": "PJSIP/2001-00000047", "claimed_by_user": null, "linked_id": "1772881979.108", "operator_extension": "2001", "source": "asterisk"}}, {"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:01+00:00", "event_id": "vev_8ece03ec07", "event_type": "call.started", "interaction_id": "int_0349796d1e", "payload": {"caller_name": "1001", "caller_number": "1001", "context": "from-softphones", "direction": "inbound", "extension": "7000", "linked_id": "1772881979.108", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:53+00:00", "event_id": "vev_9245c8bb30", "event_type": "recording.ready", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": 58, "file_name": "20260307-160753-1772881673.105.wav", "linked_id": "1772881673.105", "mime_type": "audio/wav", "remote_path": "/var/spool/asterisk/monitor/mvpcc/20260307-160753-1772881673.105.wav", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:53+00:00", "event_id": "vev_3e2c4eedc7", "event_type": "call.ended", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": 58, "hangup_cause": "16", "linked_id": "1772881673.105", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:20+00:00", "event_id": "vev_dbf1ba4399", "event_type": "call.ended", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": null, "hangup_cause": "unknown", "linked_id": "1772881673.105", "reconciled": true, "source": "asterisk"}}] --[2026-03-07T16:13:30+05:00] live-calls [] --[2026-03-07T16:13:30+05:00] recent-calls [{"call_id": "1772881979.108", "caller_name": "1001", "caller_number": "1001", "channel_name": "PJSIP/2001-00000047", "claimed_at": null, "claimed_by_user": null, "connected_at": "2026-03-07T11:13:05+00:00", "ended_at": "2026-03-07T11:13:30+00:00", "hangup_cause": "16", "has_recording": false, "interaction_id": "int_0349796d1e", "last_transition_at": "2026-03-07T11:13:30+00:00", "operator_extension": "2001", "queue_code": "voice_lab", "queue_id": "que_23fb0ad07c", "started_at": "2026-03-07T11:13:01+00:00", "status": "ended", "telephony_status": "ended", "terminal_action": "ended", "terminal_target": null, "updated_at": "2026-03-07T11:13:30+00:00"}] --[2026-03-07T16:13:30+05:00] voice-events [{"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:30+00:00", "event_id": "vev_42cd34ba3b", "event_type": "recording.ready", "interaction_id": "int_0349796d1e", "payload": {"duration_seconds": 28, "file_name": "20260307-161300-1772881979.108.wav", "linked_id": "1772881979.108", "mime_type": "audio/wav", "remote_path": "/var/spool/asterisk/monitor/mvpcc/20260307-161300-1772881979.108.wav", "source": "asterisk"}}, {"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:30+00:00", "event_id": "vev_dcf3378912", "event_type": "call.ended", "interaction_id": "int_0349796d1e", "payload": {"duration_seconds": 28, "hangup_cause": "16", "linked_id": "1772881979.108", "source": "asterisk"}}, {"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:26+00:00", "event_id": "vev_9a03fc9ff8", "event_type": "call.ended", "interaction_id": "int_0349796d1e", "payload": {"duration_seconds": null, "hangup_cause": "unknown", "linked_id": "1772881979.108", "reconciled": true, "source": "asterisk"}}, {"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:05+00:00", "event_id": "vev_e3bec0b0e6", "event_type": "call.connected", "interaction_id": "int_0349796d1e", "payload": {"channel_name": "PJSIP/2001-00000047", "claimed_by_user": null, "linked_id": "1772881979.108", "operator_extension": "2001", "source": "asterisk"}}, {"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:01+00:00", "event_id": "vev_8ece03ec07", "event_type": "call.started", "interaction_id": "int_0349796d1e", "payload": {"caller_name": "1001", "caller_number": "1001", "context": "from-softphones", "direction": "inbound", "extension": "7000", "linked_id": "1772881979.108", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:53+00:00", "event_id": "vev_9245c8bb30", "event_type": "recording.ready", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": 58, "file_name": "20260307-160753-1772881673.105.wav", "linked_id": "1772881673.105", "mime_type": "audio/wav", "remote_path": "/var/spool/asterisk/monitor/mvpcc/20260307-160753-1772881673.105.wav", "source": "asterisk"}}] --[2026-03-07T16:13:30+05:00] call-links [{"call_id": "1772881979.108", "claimed_by_user": null, "connected_at": "2026-03-07T11:13:05+00:00", "ended_at": "2026-03-07T11:13:30+00:00", "interaction_id": "int_0349796d1e", "operator_extension": "2001", "started_at": "2026-03-07T11:13:01+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772881673.105", "claimed_by_user": "operator", "connected_at": "2026-03-07T11:07:57+00:00", "ended_at": "2026-03-07T11:08:53+00:00", "interaction_id": "int_052586cdaa", "operator_extension": "2001", "started_at": "2026-03-07T11:07:55+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772876380.93", "claimed_by_user": null, "connected_at": "2026-03-07T09:39:49+00:00", "ended_at": "2026-03-07T09:40:12+00:00", "interaction_id": "int_69443612e1", "operator_extension": "2001", "started_at": "2026-03-07T09:39:42+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772875171.90", "claimed_by_user": null, "connected_at": "2026-03-07T09:19:36+00:00", "ended_at": "2026-03-07T09:19:39+00:00", "interaction_id": "int_726c36d95d", "operator_extension": "2001", "started_at": "2026-03-07T09:19:33+00:00", "status": "ended", "telephony_status": "ended"}] --[2026-03-07T16:13:31+05:00] recent-calls [{"call_id": "1772881979.108", "caller_name": "1001", "caller_number": "1001", "channel_name": "PJSIP/2001-00000047", "claimed_at": null, "claimed_by_user": null, "connected_at": "2026-03-07T11:13:05+00:00", "ended_at": "2026-03-07T11:13:30+00:00", "hangup_cause": "16", "has_recording": true, "interaction_id": "int_0349796d1e", "last_transition_at": "2026-03-07T11:13:30+00:00", "operator_extension": "2001", "queue_code": "voice_lab", "queue_id": "que_23fb0ad07c", "started_at": "2026-03-07T11:13:01+00:00", "status": "ended", "telephony_status": "ended", "terminal_action": "ended", "terminal_target": null, "updated_at": "2026-03-07T11:13:30+00:00"}] --[2026-03-07T16:13:32+05:00] monitor {"status": "finished"} -\ No newline at end of file -diff --git a/docs/acceptance/track12/track12-acceptance.template.md b/docs/acceptance/track12/track12-acceptance.template.md -deleted file mode 100644 -index a5eb685..0000000 ---- a/docs/acceptance/track12/track12-acceptance.template.md -+++ /dev/null -@@ -1,38 +0,0 @@ --# Track 12 Acceptance - -- --## Environment -- --- Base URL: `` --- Database: `` --- Asterisk host: `` --- Operator user: `` --- Operator extension: `` -- --## Executed scenarios -- --- [ ] Happy path: answer in SIP -> `╨Я╤А╨╕╨╜╤П╤В╤М ╨▓ ╤А╨░╨▒╨╛╤В╤Г` -> `╨Ч╨░╨▓╨╡╤А╤И╨╕╤В╤М` --- [ ] Transfer path: answer in SIP -> `╨Я╤А╨╕╨╜╤П╤В╤М ╨▓ ╤А╨░╨▒╨╛╤В╤Г` -> `╨Я╨╡╤А╨╡╨┤╨░╤В╤М` --- [ ] Passive path: answer in SIP, no browser action, call stays active until real hangup --- [ ] No-fake-cards path: `╨Ц╨╕╨▓╤Л╨╡ ╨╖╨▓╨╛╨╜╨║╨╕` does not show `interaction:int_*` -- --## Evidence -- --- Active live call snapshot: `operator-live-calls.json` --- Recent call snapshot: `operator-recent-calls.json` --- Action log: `bridge-actions.json` --- Playback proof: `playback-proof.json` -- --## Mandatory checks -- --- [ ] `GET /proxy/asterisk-bridge/asterisk/live-calls` returns only active calls --- [ ] `GET /proxy/asterisk-bridge/asterisk/recent-calls` returns only recent ended/transferred calls --- [ ] `claim` keeps the card in `╨Р╨║╤В╨╕╨▓╨╜╤Л╨╡ ╨╖╨▓╨╛╨╜╨║╨╕` --- [ ] `hangup` moves the card to `╨в╨╛╨╗╤М╨║╨╛ ╤З╤В╨╛ ╨╖╨░╨▓╨╡╤А╤И╤С╨╜╨╜╤Л╨╡` --- [ ] `blind transfer` moves the card to `╨в╨╛╨╗╤М╨║╨╛ ╤З╤В╨╛ ╨╖╨░╨▓╨╡╤А╤И╤С╨╜╨╜╤Л╨╡` --- [ ] recording is available in `/supervisor` -- --## Decision -- --- GO / NO-GO: `` --- Notes: -- - `` -diff --git a/docs/acceptance/track14/track14-acceptance.template.md b/docs/acceptance/track14/track14-acceptance.template.md -deleted file mode 100644 -index d64cd08..0000000 ---- a/docs/acceptance/track14/track14-acceptance.template.md -+++ /dev/null -@@ -1,38 +0,0 @@ --# Track 14 Acceptance -- --## Environment -- --- Browser: `` --- Gateway base URL: `` --- Asterisk host: `` --- WSS URL: `` --- Operator app user: `` --- Browser SIP auth user: `` -- --## Scenario -- --1. Open `/operator` in Chrome or Edge. --2. Connect browser softphone. --3. Place one inbound call `1001 -> 7000`. --4. Answer in the browser. --5. Confirm auto-claim or perform manual `╨Я╤А╨╕╨╜╤П╤В╤М ╨▓ ╤А╨░╨▒╨╛╤В╤Г`. --6. Hang up through the browser path. --7. Confirm the call moves to recent and the recording is playable in `/supervisor`. -- --## Evidence -- --- Browser softphone registration state screenshot --- Operator live-call screenshot while ringing or connected --- Browser answer or hangup action proof --- Bridge actions snapshot --- Recent-calls snapshot --- Recording playback proof -- --## Result -- --- `GO` or `NO-GO` --- Notes: -- - browser registration -- - answer behavior -- - auto-claim result -- - recording result -diff --git a/docs/acceptance/track15/track15-acceptance.template.md b/docs/acceptance/track15/track15-acceptance.template.md -deleted file mode 100644 -index 9b3a2a9..0000000 ---- a/docs/acceptance/track15/track15-acceptance.template.md -+++ /dev/null -@@ -1,41 +0,0 @@ --# Track 15 Acceptance -- --## Environment -- --- Base URL: --- Telegram bot username: --- Queue ID: --- Operator user: --- Date/time: -- --## Scenario -- --1. Send the first Telegram message to the bot. --2. Open `/operator` -> `Telegram`. --3. Confirm a new thread appears and links to a Telegram interaction. --4. Claim the thread. --5. Send a reply from the operator page. --6. Send another inbound message from the same Telegram chat. --7. Confirm the same thread and same interaction are reused. --8. Close the thread. --9. Send one more inbound message and confirm reactivation to `new`. -- --## Expected results -- --- thread created on first inbound --- same thread reused on repeated inbound from the same `chat_id` --- operator reply persisted and delivered --- closed interaction reactivated on new inbound --- unsupported content, if tested, is shown as a system placeholder -- --## Evidence -- --- thread payload: --- messages payload: --- linked interaction payload: --- reply proof: --- reactivation proof: -- --## Result -- --- PASS / FAIL -diff --git a/docs/acceptance/track9/README.md b/docs/acceptance/track9/README.md -deleted file mode 100644 -index 7681e48..0000000 ---- a/docs/acceptance/track9/README.md -+++ /dev/null -@@ -1,27 +0,0 @@ --# Track 9 Acceptance Evidence -- --Use this folder for formal QA-lab acceptance artifacts for Asterisk integration. -- --Recommended structure: -- --- `docs/acceptance/track9//...` for each run --- include: -- - bridge status snapshots -- - smoke/check outputs -- - sampled DB evidence -- - final acceptance note -- --Generate a baseline package with: -- --```powershell --python scripts\track9_collect_evidence.py --base-url http://localhost:8080 --database-url --run-checks --require-recording --``` -- --When `--run-checks` is used, the package now includes: --- `preflight_output.txt` --- `smoke_output.txt` --- `track9_check_output.txt` -- --For production cutover tracking (Track 9.2), start from: -- --- [track9-cutover-sheet.template.md](/e:/Zhan/docs/acceptance/track9/track9-cutover-sheet.template.md) -diff --git a/docs/acceptance/track9/track9-acceptance.md b/docs/acceptance/track9/track9-acceptance.md -deleted file mode 100644 -index 859d6ad..0000000 ---- a/docs/acceptance/track9/track9-acceptance.md -+++ /dev/null -@@ -1,50 +0,0 @@ --# Track 9 Acceptance (Latest QA Run) -- --## Environment -- --- Date: `2026-03-05` --- Host: local QA lab (`127.0.0.1`) --- Gateway URL: `http://127.0.0.1:8080` --- Asterisk VM host: `92.38.48.166` --- Database URL (masked): `sqlite:///.data_local/mvp_cc.db` --- Evidence package: [20260305_160002](/e:/Zhan/docs/acceptance/track9/20260305_160002) -- --## Executed scenario -- --1. Softphone `1001` called `7000`. --2. Bridge reported AMI connected. --3. `call.started` received from Asterisk source. --4. `call.ended` received. --5. `recording.ready` processed and recording uploaded. --6. Recording playback validated in supervisor shell. -- --## Validation commands -- --```powershell --python scripts\track9_preflight.py --base-url --check-sftp --python scripts\asterisk_lab_smoke.py --base-url --database-url --require-recording --python scripts\track9_check.py --base-url --database-url --require-recording --python scripts\track9_collect_evidence.py --base-url --database-url --run-checks --require-recording --``` -- --## Result summary -- --- AMI connected: `yes` --- call.started present: `yes` --- call.ended present: `yes` --- call links present: `yes` --- recording imported: `yes` --- failed bridge events: `0` --- smoke: `PASS` --- track9_check: `PASS` --- preflight: `PASS` -- --## Risks / open items -- --- Asterisk dialplan still emits some duplicate/late lifecycle signals in lab runs; bridge reconciliation masks this and keeps platform data consistent. --- For production cutover, keep strict auth and rollback window from Track 9.2 sheet. -- --## Decision -- --- [x] GO --- [ ] NO-GO -diff --git a/docs/acceptance/track9/track9-cutover-sheet.template.md b/docs/acceptance/track9/track9-cutover-sheet.template.md -deleted file mode 100644 -index 06c37a9..0000000 ---- a/docs/acceptance/track9/track9-cutover-sheet.template.md -+++ /dev/null -@@ -1,87 +0,0 @@ --# Track 9.2 Cutover Sheet (Template) -- --## Window -- --- Date: --- Start time: --- End time: --- Change ticket: -- --## Owners -- --- K8s owner: --- Voice/Asterisk owner: --- Application owner: --- On-call: -- --## Environment contract -- --- `KUBE_CONTEXT`: --- `NAMESPACE`: --- `RELEASE`: --- `GATEWAY_BASE_URL`: --- `DATABASE_URL`: --- `IMAGE_TAG`: --- `ASTERISK_AMI_HOST`: --- `ASTERISK_SFTP_HOST`: --- `QUEUE_ID (voice_lab)`: -- --## Entry checks (T-1 day) -- --- [ ] QA baseline accepted: -- - `docs/acceptance/track9/track9-acceptance.md` --- [ ] Production queue map validated (`voice_lab -> queue_id` exists) --- [ ] AMI port reachable (`5038/tcp`) --- [ ] SFTP port reachable (`22/tcp`) --- [ ] PostgreSQL confirmed for target env -- --## Dry-run results (T-4h to T-1h) -- --- [ ] `helm lint` PASS --- [ ] `helm template` PASS --- [ ] strict env keys found in rendered manifest --- Artifact folder: -- --## Execution (T0) -- --- Helm command: --- Start time: --- End time: --- Result: -- --Rollout statuses: -- --- [ ] `api-gateway` ready --- [ ] `asterisk-bridge-service` ready --- [ ] `voice-adapter-service` ready --- [ ] `recording-service` ready -- --## Post-cutover checks -- --- [ ] `track9_preflight --check-sftp --require-strict-service-auth` PASS --- [ ] Real call completed (`1001 -> 7000`) --- [ ] `asterisk_lab_smoke --require-recording` PASS --- [ ] `track9_check --require-recording` PASS --- [ ] Supervisor playback confirmed (`HTTP 200`, `audio/*`) -- --## Evidence -- --- Evidence folder: --- `track9-acceptance.md`: --- `playback-proof.md`: --- `playback-proof.json` (if used): -- --## Decision -- --- [ ] GO --- [ ] NO-GO -- --Reason: -- --## Rollback section (fill if NO-GO) -- --- Trigger: --- Rollback command: --- Rollback completion time: --- Post-rollback preflight result: --- Incident / RCA ticket: -diff --git a/docs/acceptance/track9_1/README.md b/docs/acceptance/track9_1/README.md -deleted file mode 100644 -index 2241116..0000000 ---- a/docs/acceptance/track9_1/README.md -+++ /dev/null -@@ -1,18 +0,0 @@ --# Track 9.1 Acceptance (Latest Strict Auth Baseline) -- --## Summary -- --Track 9.1 captures the strict service-token hardening pass for the Asterisk bridge flow. -- --- strict auth profile: `ALLOW_LEGACY_HEADER_AUTH=0` --- bridge auth mode: `ASTERISK_BRIDGE_AUTH_MODE=bearer` --- trusted service subjects enforced for voice ingest and recording import -- --## Latest retained evidence package -- --- [20260305_042801](/e:/Zhan/docs/acceptance/track9_1/20260305_042801) -- --## Notes -- --- Earlier timestamp bundles were reduced during repository cleanup. --- Use the retained package for low-level QA artifacts and Track 9.1 reference data. -diff --git a/services/ai_orchestrator_service/app.py b/services/ai_orchestrator_service/app.py -index 891669e..848ab51 100644 ---- a/services/ai_orchestrator_service/app.py -+++ b/services/ai_orchestrator_service/app.py -@@ -79,6 +79,7 @@ from services.shared.sql_models import ( - VoiceAISessionRow, - ) - from services.ai_orchestrator_service import voice as voice_flows -+from services.ai_orchestrator_service import operator_persona as persona - from services.ai_orchestrator_service.voice_name_config import ( - load_voice_name_collection_config, - save_voice_name_collection_config, -@@ -2316,15 +2317,10 @@ def _openai_prompt( - } - for article in kb_results - ] -- system_prompt = ( -- f"You are the company's AI assistant for {channel_label}. " -- "Always disclose you are an AI assistant in the first meaningful reply. " -- "Use only provided business context, KB snippets, and interaction state. " -- "Never invent order statuses, tariffs, discounts, deadlines, or actions that are not in context. " -- "If confidence is low or a human is needed, set needs_handoff=true and do not bluff. " -- "Return only a JSON object with keys: language, intent, reply_text, confidence, needs_handoff, " -- "handoff_reason, case_action, kb_refs. case_action must be one of none, close, escalate, keep_open. " -- f"Prefer {'Kazakh' if language == 'kz' else 'Russian'} for the reply." -+ system_prompt = persona.operator_system_prompt( -+ language=language, -+ channel_label=channel_label, -+ is_voice=False, - ) - user_prompt = { - "customer": customer_summary, -@@ -2348,17 +2344,7 @@ def _openai_prompt( - ] - - --def _openai_compatible_decision( -- *, -- customer: Customer | None, -- interaction: Interaction, -- thread: Any, -- messages: list[Any], -- kb_results: list[KBArticleRow], -- language: str, -- channel_label: str = "Telegram", -- channel_key: str = "telegram", --) -> dict[str, Any]: -+def _request_structured_model_decision(messages: list[dict[str, str]]) -> dict[str, Any]: - if not _ai_api_base() or not _ai_api_key(): - raise RuntimeError("AI_API_BASE / AI_API_KEY are required for openai_compatible provider") - started = time.perf_counter() -@@ -2366,16 +2352,7 @@ def _openai_compatible_decision( - "model": _ai_model(), - "temperature": 0.2, - "response_format": {"type": "json_object"}, -- "messages": _openai_prompt( -- customer=customer, -- interaction=interaction, -- thread=thread, -- messages=messages, -- kb_results=kb_results, -- language=language, -- channel_label=channel_label, -- channel_key=channel_key, -- ), -+ "messages": messages, - } - with httpx.Client(timeout=_ai_timeout_seconds()) as client: - response = client.post( -@@ -2395,6 +2372,31 @@ def _openai_compatible_decision( - return decision - - -+def _openai_compatible_decision( -+ *, -+ customer: Customer | None, -+ interaction: Interaction, -+ thread: Any, -+ messages: list[Any], -+ kb_results: list[KBArticleRow], -+ language: str, -+ channel_label: str = "Telegram", -+ channel_key: str = "telegram", -+) -> dict[str, Any]: -+ return _request_structured_model_decision( -+ _openai_prompt( -+ customer=customer, -+ interaction=interaction, -+ thread=thread, -+ messages=messages, -+ kb_results=kb_results, -+ language=language, -+ channel_label=channel_label, -+ channel_key=channel_key, -+ ) -+ ) -+ -+ - def _sanitize_decision(raw: dict[str, Any], *, fallback_language: str) -> dict[str, Any]: - decision = { - "language": str(raw.get("language") or fallback_language or "ru"), -@@ -2442,6 +2444,90 @@ def _always_reply_fallback(last_user_text: str, language: str) -> str: - ) - - -+def _stub_decision( -+ *, -+ customer: Customer | None, -+ interaction: Interaction, -+ last_user_message: TelegramMessageRow, -+ kb_results: list[KBArticleRow], -+ language: str, -+) -> dict[str, Any]: -+ text = last_user_message.text -+ if _looks_like_human_request(text): -+ return { -+ "language": language, -+ "intent": "handoff_request", -+ "reply_text": "", -+ "confidence": 0.2, -+ "needs_handoff": True, -+ "handoff_reason": "╨Ъ╨╗╨╕╨╡╨╜╤В ╨╖╨░╨┐╤А╨╛╤Б╨╕╨╗ ╨╢╨╕╨▓╨╛╨│╨╛ ╨╛╨┐╨╡╤А╨░╤В╨╛╤А╨░.", -+ "case_action": "keep_open", -+ "kb_refs": [], -+ } -+ if _is_sensitive_request(text): -+ return { -+ "language": language, -+ "intent": "sensitive_request", -+ "reply_text": "", -+ "confidence": 0.25, -+ "needs_handoff": True, -+ "handoff_reason": "╨Э╤Г╨╢╨╡╨╜ ╤З╨╡╨╗╨╛╨▓╨╡╨║: ╨╖╨░╨┐╤А╨╛╤Б ╨╖╨░╤В╤А╨░╨│╨╕╨▓╨░╨╡╤В ╤З╤Г╨▓╤Б╤В╨▓╨╕╤В╨╡╨╗╤М╨╜╤Г╤О ╤В╨╡╨╝╤Г ╨╕╨╗╨╕ ╨┤╨╡╨╣╤Б╤В╨▓╨╕╨╡ ╨▓╨╜╨╡ ╨┤╨╛╤Б╤В╤Г╨┐╨╜╨╛╨│╨╛ ╨║╨╛╨╜╤В╨╡╨║╤Б╤В╨░.", -+ "case_action": "keep_open", -+ "kb_refs": [], -+ } -+ if _looks_like_resolution_confirmation(text): -+ return { -+ "language": language, -+ "intent": "resolution_confirmed", -+ "reply_text": persona.text_resolution_reply(language), -+ "confidence": 0.9, -+ "needs_handoff": False, -+ "handoff_reason": None, -+ "case_action": "close", -+ "kb_refs": [], -+ } -+ if kb_results: -+ best = kb_results[0] -+ snippet = _article_snippet(best) -+ return { -+ "language": language, -+ "intent": "kb_answer", -+ "reply_text": ( -+ f"╥Ъ╤Л╤Б╥Ы╨░╤И╨░ ╨░╨╣╤В╨░╨╣╤Л╨╜: {snippet}" -+ if language == "kz" -+ else f"╨Ъ╨╛╤А╨╛╤В╨║╨╛ ╨┐╨╛╨┤╤Б╨║╨░╨╢╤Г: {snippet}" -+ ), -+ "confidence": 0.84, -+ "needs_handoff": False, -+ "handoff_reason": None, -+ "case_action": "keep_open", -+ "kb_refs": [best.article_id], -+ } -+ return { -+ "language": language, -+ "intent": "clarification", -+ "reply_text": ( -+ "╨Э╨░╥Ы╤В╤Л╤А╨░╥Ы ╨░╨╣╤В╤Л╥г╤Л╨╖╤И╤Л: ╨╜╨╡╨╜╤Ц ╤В╨╡╨║╤Б╨╡╤А╤Г ╨╜╨╡╨╝╨╡╤Б╨╡ ╨╜╨░╥Ы╤В╤Л╨╗╨░╤Г ╨║╨╡╤А╨╡╨║?" -+ if language == "kz" -+ else "╨г╤В╨╛╤З╨╜╨╕╤В╨╡, ╨┐╨╛╨╢╨░╨╗╤Г╨╣╤Б╤В╨░, ╤З╤В╨╛ ╨╕╨╝╨╡╨╜╨╜╨╛ ╨╜╤Г╨╢╨╜╨╛ ╨┐╤А╨╛╨▓╨╡╤А╨╕╤В╤М ╨╕╨╗╨╕ ╨┐╨╛╨┤╤Б╨║╨░╨╖╨░╤В╤М." -+ ), -+ "confidence": 0.45, -+ "needs_handoff": False, -+ "handoff_reason": None, -+ "case_action": "keep_open", -+ "kb_refs": [], -+ } -+ -+ -+def _always_reply_fallback(last_user_text: str, language: str) -> str: -+ normalized = str(last_user_text or "").strip().lower() -+ greeting_tokens = ("╨┐╤А╨╕╨▓╨╡╤В", "╨╖╨┤╤А╨░╨▓╤Б╤В╨▓╤Г╨╣╤В╨╡", "╨┤╨╛╨▒╤А╤Л╨╣", "╤Б╨░╨╗╨╡╨╝", "╤Б╙Щ", "╤Б╙Щ╨╗╨╡╨╝", "hello", "hi") -+ return persona.human_fallback_reply( -+ language, -+ is_greeting=any(token in normalized for token in greeting_tokens), -+ ) -+ -+ - def _apply_always_reply_mode( - decision: dict[str, Any], - *, -diff --git a/services/ai_orchestrator_service/voice.py b/services/ai_orchestrator_service/voice.py -index 571d87e..bc3e254 100644 ---- a/services/ai_orchestrator_service/voice.py -+++ b/services/ai_orchestrator_service/voice.py -@@ -22,6 +22,7 @@ from services.shared.sql_models import ( - VoiceAISessionRow, - VoiceTranscriptSegmentRow, - ) -+from services.ai_orchestrator_service import operator_persona as persona - from services.ai_orchestrator_service.voice_name_config import ( - load_effective_voice_name_collection_config, - voice_name_collection_confirmation_greeting, -@@ -71,6 +72,18 @@ def _voice_handoff_reply(language: str) -> str: - ) - - -+def _voice_disclosure_prefix(language: str) -> str: -+ return persona.voice_disclosure_prefix(language) -+ -+ -+def _voice_greeting(language: str) -> str: -+ return persona.voice_greeting(language) -+ -+ -+def _voice_handoff_reply(language: str) -> str: -+ return persona.voice_handoff_reply(language) -+ -+ - def _normalize_phone(value: str | None) -> str | None: - raw = str(value or "").strip() - if not raw: -@@ -1037,6 +1050,132 @@ def _ensure_voice_ai_session( - return ai_session, True - - -+def _voice_policy_mode() -> str: -+ return persona.voice_policy_mode() -+ -+ -+def _voice_llm_prompt_messages( -+ *, -+ language: str, -+ customer: Customer | None, -+ interaction: Interaction, -+ transcript_text: str, -+ transcript_window: list[VoiceTranscriptSegmentRow], -+ kb_results: list[Any], -+ name_value: str | None, -+ name_status: str | None, -+) -> list[dict[str, str]]: -+ app = _app() -+ history = [ -+ { -+ "speaker": segment.speaker, -+ "text": segment.text, -+ "sequence_no": segment.sequence_no, -+ "source_type": segment.source_type, -+ "interrupted": bool(segment.barge_in_interrupted), -+ "created_at": segment.created_at, -+ } -+ for segment in transcript_window[-6:] -+ ] -+ kb_context = [ -+ { -+ "article_id": article.article_id, -+ "title": article.title, -+ "snippet": app._article_snippet(article, limit=240), -+ } -+ for article in kb_results[:3] -+ ] -+ payload = { -+ "customer": { -+ "customer_id": customer.customer_id if customer else interaction.customer_id, -+ "display_name": customer.display_name if customer else None, -+ "name_status": name_status, -+ "name_value": name_value, -+ "channel": "voice", -+ }, -+ "interaction": { -+ "interaction_id": interaction.interaction_id, -+ "status": interaction.status, -+ "queue_id": interaction.queue_id, -+ "subject": interaction.subject, -+ }, -+ "voice_turn": { -+ "last_user_text": transcript_text, -+ "language": language, -+ }, -+ "kb_results": kb_context, -+ "history": history, -+ } -+ return [ -+ { -+ "role": "system", -+ "content": persona.operator_system_prompt( -+ language=language, -+ channel_label="Voice", -+ is_voice=True, -+ ), -+ }, -+ {"role": "user", "content": json.dumps(payload, ensure_ascii=False)}, -+ ] -+ -+ -+def _voice_llm_decision( -+ *, -+ language: str, -+ customer: Customer | None, -+ interaction: Interaction, -+ transcript_text: str, -+ transcript_window: list[VoiceTranscriptSegmentRow], -+ kb_results: list[Any], -+ name_value: str | None, -+ name_status: str | None, -+) -> dict[str, Any] | None: -+ app = _app() -+ if _voice_policy_mode() != "llm_guarded": -+ return None -+ if app._ai_provider() != "openai_compatible": -+ return None -+ try: -+ raw = app._request_structured_model_decision( -+ _voice_llm_prompt_messages( -+ language=language, -+ customer=customer, -+ interaction=interaction, -+ transcript_text=transcript_text, -+ transcript_window=transcript_window, -+ kb_results=kb_results, -+ name_value=name_value, -+ name_status=name_status, -+ ) -+ ) -+ except Exception: -+ return None -+ decision = app._sanitize_decision(raw, fallback_language=language) -+ if not str(decision.get("reply_text") or "").strip(): -+ decision["reply_text"] = _voice_generic_prompt(language) -+ if decision.get("needs_handoff") and not decision.get("handoff_reason"): -+ decision["handoff_reason"] = "╨в╤А╨╡╨▒╤Г╨╡╤В╤Б╤П ╤Г╤З╨░╤Б╤В╨╕╨╡ ╨╢╨╕╨▓╨╛╨│╨╛ ╨╛╨┐╨╡╤А╨░╤В╨╛╤А╨░." -+ if decision.get("needs_handoff") and not str(decision.get("reply_text") or "").strip(): -+ decision["reply_text"] = _voice_handoff_reply(language) -+ return { -+ "language": decision["language"], -+ "intent": decision["intent"], -+ "reply_text": str(decision["reply_text"] or "").strip(), -+ "confidence": float(decision["confidence"] or 0.0), -+ "needs_handoff": bool(decision["needs_handoff"]), -+ "handoff_reason": decision["handoff_reason"], -+ "case_action": decision["case_action"], -+ "kb_refs": decision["kb_refs"], -+ "summary_text": ( -+ str(decision.get("reply_text") or "").strip() -+ or str(decision.get("handoff_reason") or "").strip() -+ or "AI ╨┐╨╛╨┤╨│╨╛╤В╨╛╨▓╨╕╨╗ ╨╛╤В╨▓╨╡╤В ╨╛╨┐╨╡╤А╨░╤В╨╛╤А╤Б╨║╨╕╨╝ ╤Б╤В╨╕╨╗╨╡╨╝." -+ ), -+ "model": decision["_model"], -+ "latency_ms": decision["_latency_ms"], -+ } -+ -+ - def _voice_decision_legacy( - *, - language: str, -@@ -1118,6 +1257,8 @@ def _voice_decision( - transcript_window: list[VoiceTranscriptSegmentRow], - kb_results: list[Any], - disclosure_required: bool, -+ customer_name_value: str | None = None, -+ customer_name_status: str | None = None, - ) -> dict[str, Any]: - app = _app() - normalized = str(transcript_text or "").strip() -@@ -1209,6 +1350,114 @@ def _voice_decision( - } - - -+def _voice_decision( -+ *, -+ language: str, -+ customer: Customer | None, -+ interaction: Interaction, -+ transcript_text: str, -+ transcript_window: list[VoiceTranscriptSegmentRow], -+ kb_results: list[Any], -+ disclosure_required: bool, -+ customer_name_value: str | None = None, -+ customer_name_status: str | None = None, -+) -> dict[str, Any]: -+ app = _app() -+ normalized = str(transcript_text or "").strip() -+ lower_text = normalized.lower() -+ caller_texts = _voice_recent_caller_texts(transcript_window) -+ model = app._ai_model() -+ -+ if app._looks_like_human_request(lower_text) or app._is_sensitive_request(lower_text): -+ return { -+ "language": language, -+ "intent": "handoff_request", -+ "reply_text": _voice_handoff_reply(language), -+ "confidence": 0.25, -+ "needs_handoff": True, -+ "handoff_reason": "╨Ч╨░╨┐╤А╨╛╤Б ╤В╤А╨╡╨▒╤Г╨╡╤В ╤Г╤З╨░╤Б╤В╨╕╤П ╨╢╨╕╨▓╨╛╨│╨╛ ╨╛╨┐╨╡╤А╨░╤В╨╛╤А╨░.", -+ "case_action": "keep_open", -+ "kb_refs": [], -+ "summary_text": "AI ╤Б╨╛╨▒╤А╨░╨╗ ╨┐╨╡╤А╨▓╨╕╤З╨╜╤Л╨╣ ╨║╨╛╨╜╤В╨╡╨║╤Б╤В ╨╕ ╨╖╨░╨┐╤А╨╛╤Б╨╕╨╗ ╨╢╨╕╨▓╨╛╨│╨╛ ╨╛╨┐╨╡╤А╨░╤В╨╛╤А╨░.", -+ "model": model, -+ "latency_ms": 1, -+ } -+ -+ clarification_count = _voice_recent_clarification_count(transcript_window) -+ repeated_reply_count = _voice_repeated_assistant_reply_count(transcript_window) -+ recent_caller_window = caller_texts[-3:] -+ low_signal_count = sum(1 for text in recent_caller_window if _voice_is_low_signal_caller_text(text)) -+ caller_confused = _voice_is_confused_caller_text(normalized) -+ if clarification_count >= 4 or ( -+ clarification_count >= 3 and (caller_confused or low_signal_count >= 2 or repeated_reply_count >= 2) -+ ): -+ reply_text, handoff_reason, summary_text = _voice_loop_handoff(language) -+ return { -+ "language": language, -+ "intent": "handoff_request", -+ "reply_text": reply_text, -+ "confidence": 0.34, -+ "needs_handoff": True, -+ "handoff_reason": handoff_reason, -+ "case_action": "keep_open", -+ "kb_refs": [], -+ "summary_text": summary_text, -+ "model": model, -+ "latency_ms": 1, -+ } -+ -+ llm_decision = _voice_llm_decision( -+ language=language, -+ customer=customer, -+ interaction=interaction, -+ transcript_text=transcript_text, -+ transcript_window=transcript_window, -+ kb_results=kb_results, -+ name_value=customer_name_value or (customer.display_name if customer else None), -+ name_status=customer_name_status, -+ ) -+ if llm_decision is not None: -+ return llm_decision -+ -+ if kb_results: -+ article = kb_results[0] -+ snippet = app._article_snippet(article, limit=220) -+ return { -+ "language": language, -+ "intent": "kb_answer", -+ "reply_text": ( -+ f"╥Ъ╤Л╤Б╥Ы╨░╤И╨░ ╨░╨╣╤В╨░╨╣╤Л╨╜: {snippet}" -+ if language == "kz" -+ else f"╨Ъ╨╛╤А╨╛╤В╨║╨╛ ╨┐╨╛╨┤╤Б╨║╨░╨╢╤Г: {snippet}" -+ ), -+ "confidence": 0.78, -+ "needs_handoff": False, -+ "handoff_reason": None, -+ "case_action": "keep_open", -+ "kb_refs": [article.article_id], -+ "summary_text": "AI ╨┤╨░╨╗ ╨╛╤В╨▓╨╡╤В ╨┐╨╛ ╨┤╨╛╤Б╤В╤Г╨┐╨╜╨╛╨╝╤Г ╨║╨╛╨╜╤В╨╡╨║╤Б╤В╤Г.", -+ "model": "voice_policy_fallback", -+ "latency_ms": 1, -+ } -+ -+ reply_text = _voice_confusion_prompt(language, caller_texts) if caller_confused else ( -+ _voice_topic_prompt(language, caller_texts) or _voice_generic_prompt(language) -+ ) -+ return { -+ "language": language, -+ "intent": "clarification", -+ "reply_text": reply_text, -+ "confidence": 0.62, -+ "needs_handoff": False, -+ "handoff_reason": None, -+ "case_action": "keep_open", -+ "kb_refs": [], -+ "summary_text": "AI ╤Г╤В╨╛╤З╨╜╤П╨╡╤В ╤Ж╨╡╨╗╤М ╨╖╨▓╨╛╨╜╨║╨░ ╨╕ ╤Б╨╛╨▒╨╕╤А╨░╨╡╤В ╨║╨╛╨╜╤В╨╡╨║╤Б╤В.", -+ "model": "voice_policy_fallback", -+ "latency_ms": 1, -+ } -+ -+ - def start_voice_session(session_id: str, payload: VoiceAIStartIn) -> VoiceAIStartOut: - session = get_session() - try: -@@ -1685,6 +1934,8 @@ def turn_voice_session(session_id: str, payload: VoiceAITurnIn) -> VoiceAITurnDe - transcript_window=transcript_window, - kb_results=kb_results, - disclosure_required=disclosure_required, -+ customer_name_value=name_update["value"], -+ customer_name_status=name_update["status"], - ) - decision_metadata = _voice_name_metadata( - language=voice_session.voice_start_language or ai_session.language or "ru", -diff --git a/services/ai_orchestrator_service/voice_name_config.py b/services/ai_orchestrator_service/voice_name_config.py -index 413c718..dfc13fb 100644 ---- a/services/ai_orchestrator_service/voice_name_config.py -+++ b/services/ai_orchestrator_service/voice_name_config.py -@@ -50,6 +50,34 @@ def voice_name_collection_default_config() -> VoiceNameCollectionConfig: - ) - - -+def voice_name_collection_default_config() -> VoiceNameCollectionConfig: -+ return VoiceNameCollectionConfig( -+ enabled=True, -+ texts=VoiceNameCollectionTextsConfig( -+ ru=VoiceNameCollectionLanguageTexts( -+ start_prompt="╨Ч╨┤╤А╨░╨▓╤Б╤В╨▓╤Г╨╣╤В╨╡. ╨Э╨░╨╖╨╛╨▓╨╕╤В╨╡, ╨┐╨╛╨╢╨░╨╗╤Г╨╣╤Б╤В╨░, ╨▓╨░╤И╨╡ ╨╕╨╝╤П.", -+ personalized_greeting_template=( -+ "╨Ч╨┤╤А╨░╨▓╤Б╤В╨▓╤Г╨╣╤В╨╡, {name}. ╨Ъ╨╛╤А╨╛╤В╨║╨╛ ╤А╨░╤Б╤Б╨║╨░╨╢╨╕╤В╨╡, ╨┐╨╛╨╢╨░╨╗╤Г╨╣╤Б╤В╨░, ╤З╨╡╨╝ ╨┐╨╛╨╝╨╛╤З╤М." -+ ), -+ confirmation_greeting_template=( -+ "╨Х╤Б╨╗╨╕ ╤П ╨┐╤А╨░╨▓╨╕╨╗╤М╨╜╨╛ ╤А╨░╤Б╤Б╨╗╤Л╤И╨░╨╗, ╨▓╨░╤Б ╨╖╨╛╨▓╤Г╤В {name}? ╨Ш ╤З╨╡╨╝ ╨┐╨╛╨╝╨╛╤З╤М?" -+ ), -+ inline_followup_prompt="╨Ш ╨╡╤Й╤С ╨┐╨╛╨┤╤Б╨║╨░╨╢╨╕╤В╨╡, ╨║╨░╨║ ╨╝╨╜╨╡ ╨║ ╨▓╨░╨╝ ╨╛╨▒╤А╨░╤Й╨░╤В╤М╤Б╤П?", -+ ), -+ kz=VoiceNameCollectionLanguageTexts( -+ start_prompt="╨б╙Щ╨╗╨╡╨╝╨╡╤В╤Б╤Ц╨╖ ╨▒╨╡. ╨Р╤В╤Л╥г╤Л╨╖╨┤╤Л ╨░╤В╨░╨╣╤Б╤Л╨╖ ╨▒╨░?", -+ personalized_greeting_template=( -+ "╨б╙Щ╨╗╨╡╨╝╨╡╤В╤Б╤Ц╨╖ ╨▒╨╡, {name}. ╥Ъ╤Л╤Б╥Ы╨░╤И╨░ ╨░╨╣╤В╤Л╥г╤Л╨╖╤И╤Л, ╨╜╨╡ ╨║╙й╨╝╨╡╨║╤В╨╡╤Б╤Г ╨║╨╡╤А╨╡╨║." -+ ), -+ confirmation_greeting_template=( -+ "╨Х╨│╨╡╤А ╨┤╥▒╤А╤Л╤Б ╨╡╤Б╤В╤Ц╤Б╨╡╨╝, ╨░╤В╤Л╥г╤Л╨╖ {name}? ╥Ъ╨░╨╗╨░╨╣ ╨║╙й╨╝╨╡╨║╤В╨╡╤Б╨╡╨╝╤Ц╨╜?" -+ ), -+ inline_followup_prompt="╨в╨░╥У╤Л ╨▒╤Ц╤А ╨╜╨░╥Ы╤В╤Л╨╗╨░╤Б╨░╨╝, ╤Б╤Ц╨╖╨│╨╡ ╥Ы╨░╨╗╨░╨╣ ╥Ы╨░╤А╨░╨╣╨╝╤Л╨╜?", -+ ), -+ ), -+ ) -+ -+ - def _settings_row(session) -> VoiceNameCollectionSettingsRow | None: - return session.execute( - select(VoiceNameCollectionSettingsRow).where( -diff --git a/services/ai_voice_runtime_service/app.py b/services/ai_voice_runtime_service/app.py -index 260344d..c238f4c 100644 ---- a/services/ai_voice_runtime_service/app.py -+++ b/services/ai_voice_runtime_service/app.py -@@ -211,6 +211,14 @@ def _default_voice_greeting(language: str | None, *, agent_profile: str = "voice - ) - - -+def _default_voice_greeting(language: str | None, *, agent_profile: str = "voice_support") -> str: -+ if str(agent_profile or "").strip() == "voice_start": -+ return _voice_start_name_prompt(language) -+ if str(language or "").strip() == "kz": -+ return "╨б╙Щ╨╗╨╡╨╝╨╡╤В╤Б╤Ц╨╖ ╨▒╨╡. ╨б╥▒╤А╨░╥У╤Л╥г╤Л╨╖╨┤╤Л ╨░╨╣╤В╤Л╥г╤Л╨╖╤И╤Л, ╨║╙й╨╝╨╡╨║╤В╨╡╤Б╤Г╨│╨╡ ╤В╤Л╤А╤Л╤Б╨░╨╝╤Л╨╜." -+ return "╨Ч╨┤╤А╨░╨▓╤Б╤В╨▓╤Г╨╣╤В╨╡. ╨Я╨╛╨┤╤Б╨║╨░╨╢╨╕╤В╨╡, ╨┐╨╛╨╢╨░╨╗╤Г╨╣╤Б╤В╨░, ╤З╨╡╨╝ ╨┐╨╛╨╝╨╛╤З╤М." -+ -+ - _ASR_PROVIDER = build_asr_provider(_asr_provider_name()) - _TTS_PROVIDER = build_tts_provider(_tts_provider_name()) - -diff --git a/tests/test_ai_orchestrator_service.py b/tests/test_ai_orchestrator_service.py -index 0f25929..972343e 100644 ---- a/tests/test_ai_orchestrator_service.py -+++ b/tests/test_ai_orchestrator_service.py -@@ -1116,6 +1116,39 @@ def test_voice_module_import_helper_returns_app_module(): - assert hasattr(loaded, "_interaction_request") - - -+def test_text_openai_prompt_uses_operator_persona_without_ai_disclosure(): -+ messages = ai_module._openai_prompt( -+ customer=None, -+ interaction=SimpleNamespace( -+ interaction_id="int_text_prompt", -+ status="new", -+ queue_id="que_text", -+ subject="Need help", -+ customer_id="cust_text", -+ ), -+ thread=SimpleNamespace(thread_id="thr_text", chat_id="chat_text", display_name="Customer"), -+ messages=[ -+ SimpleNamespace( -+ author_type="customer", -+ author_id="cust_text", -+ direction="inbound", -+ text="╨е╨╛╤З╤Г ╤Г╨╖╨╜╨░╤В╤М ╤В╨░╤А╨╕╤Д", -+ created_at=utc_now_iso(), -+ ) -+ ], -+ kb_results=[], -+ language="ru", -+ channel_label="Telegram", -+ channel_key="telegram", -+ ) -+ -+ system_prompt = messages[0]["content"] -+ assert "AI assistant" not in system_prompt -+ assert "Always disclose" not in system_prompt -+ assert "human operator" in system_prompt -+ assert "Do not mention a knowledge base" in system_prompt -+ -+ - def test_voice_decision_reuses_recent_topic_instead_of_repeating_same_prompt(): - transcript_window = [ - SimpleNamespace(speaker="assistant", text=voice_module._voice_greeting("ru"), sequence_no=1), -@@ -1222,6 +1255,51 @@ def test_voice_kb_search_matches_relaxed_phrase_and_returns_kb_answer(): - assert "relaxbasicx" in decision["reply_text"].lower() - - -+def test_voice_llm_guarded_decision_uses_operator_style_without_ai_or_kb(monkeypatch): -+ monkeypatch.setenv("AI_VOICE_POLICY_MODE", "llm_guarded") -+ monkeypatch.setattr(ai_module, "_ai_provider", lambda: "openai_compatible") -+ captured: dict[str, object] = {} -+ -+ def _fake_structured(messages): -+ captured["messages"] = messages -+ return { -+ "language": "ru", -+ "intent": "kb_answer", -+ "reply_text": "╨б╨╡╨╣╤З╨░╤Б ╨┐╨╛╨┤╤Б╨║╨░╨╢╤Г: ╤Д╨╕╨╗╨╕╨░╨╗ ╨▓ ╨Р╨╗╨╝╨░╤В╤Л ╤А╨░╨▒╨╛╤В╨░╨╡╤В ╤Б 9:00 ╨┤╨╛ 18:00 ╨┐╨╛ ╨▒╤Г╨┤╨╜╤П╨╝.", -+ "confidence": 0.88, -+ "needs_handoff": False, -+ "handoff_reason": None, -+ "case_action": "keep_open", -+ "kb_refs": ["kba_voice_1"], -+ "_model": "gpt-test", -+ "_latency_ms": 42, -+ "_finish_reason": "stop", -+ } -+ -+ monkeypatch.setattr(ai_module, "_request_structured_model_decision", _fake_structured) -+ -+ kb_article = SimpleNamespace(article_id="kba_voice_1", title="╨У╤А╨░╤Д╨╕╨║ ╤А╨░╨▒╨╛╤В╤Л", body="╨С╤Г╨┤╨╜╨╕ 9:00-18:00") -+ decision = voice_module._voice_decision( -+ language="ru", -+ customer=SimpleNamespace(customer_id="cus_voice_1", display_name="╨Р╨╣╨┤╨╛╤Б"), -+ interaction=SimpleNamespace(interaction_id="int_voice_llm", status="new", queue_id="que_voice", subject="hours"), -+ transcript_text="╨Ъ╨░╨║ ╤А╨░╨▒╨╛╤В╨░╨╡╤В ╤Д╨╕╨╗╨╕╨░╨╗ ╨▓ ╨Р╨╗╨╝╨░╤В╤Л?", -+ transcript_window=[SimpleNamespace(speaker="caller", text="╨Ъ╨░╨║ ╤А╨░╨▒╨╛╤В╨░╨╡╤В ╤Д╨╕╨╗╨╕╨░╨╗ ╨▓ ╨Р╨╗╨╝╨░╤В╤Л?", sequence_no=1, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso())], -+ kb_results=[kb_article], -+ disclosure_required=False, -+ customer_name_value="╨Р╨╣╨┤╨╛╤Б", -+ customer_name_status="name_obtained", -+ ) -+ -+ assert decision["intent"] == "kb_answer" -+ assert decision["model"] == "gpt-test" -+ assert "ai" not in decision["reply_text"].lower() -+ assert "╨▒╨░╨╖╨░ ╨╖╨╜╨░╨╜╨╕╨╣" not in decision["reply_text"].lower() -+ system_prompt = captured["messages"][0]["content"] -+ assert "human operator" in system_prompt -+ assert "Do not say or imply that you are an AI" in system_prompt -+ -+ - def test_ai_enqueue_creates_outbound_ai_reply_and_delivery_flow(monkeypatch): - monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1") - monkeypatch.setenv("AI_PROVIDER", "stub") -diff --git a/tests/test_ai_voice_runtime_service.py b/tests/test_ai_voice_runtime_service.py -index 2a4e836..bceb96a 100644 ---- a/tests/test_ai_voice_runtime_service.py -+++ b/tests/test_ai_voice_runtime_service.py -@@ -105,6 +105,95 @@ def test_create_voice_ai_session_records_greeting_without_blocking_on_orchestrat - raise AssertionError("background voice-session start did not finish in time") - - -+def test_create_voice_ai_session_records_greeting_without_blocking_on_orchestrator(monkeypatch): -+ allow_start = threading.Event() -+ -+ def _fake_orchestrator_request(method: str, path: str, *, payload=None, timeout=10.0): -+ assert method == "POST" -+ assert path.endswith("/start") -+ assert allow_start.wait(timeout=2.0) -+ return { -+ "session_id": "ais_voice_demo", -+ "language": "ru", -+ "greeting_text": "╨Ч╨┤╤А╨░╨▓╤Б╤В╨▓╤Г╨╣╤В╨╡. ╨Я╨╛╨┤╤Б╨║╨░╨╢╨╕╤В╨╡, ╨┐╨╛╨╢╨░╨╗╤Г╨╣╤Б╤В╨░, ╤З╨╡╨╝ ╨┐╨╛╨╝╨╛╤З╤М.", -+ "disclosure_required": True, -+ } -+ -+ monkeypatch.setattr(runtime_module, "_orchestrator_request", _fake_orchestrator_request) -+ -+ client = TestClient(runtime_module.app) -+ response_holder: dict[str, object] = {} -+ -+ def _request() -> None: -+ response_holder["response"] = client.post( -+ "/internal/voice-ai/sessions", -+ headers=_admin_headers(), -+ json={ -+ "call_id": "call_voice_runtime_1", -+ "linked_id": "linked_voice_runtime_1", -+ "interaction_id": "int_voice_runtime_1", -+ "queue_id": "que_voice_runtime_1", -+ "caller_number": "+77010000031", -+ "caller_name": "Runtime Caller", -+ "agent_profile": "voice_support", -+ "language_hint": "ru", -+ "handoff_queue_id": "que_voice_runtime_1", -+ "metadata": {"queue_code": "voice_lab"}, -+ }, -+ ) -+ -+ request_thread = threading.Thread(target=_request, daemon=True) -+ request_thread.start() -+ request_thread.join(timeout=0.3) -+ assert not request_thread.is_alive() -+ -+ response = response_holder["response"] -+ assert response.status_code == 200 -+ payload = response.json() -+ assert payload["ai_session_id"] is None -+ assert payload["status"] == "greeting" -+ -+ session = get_session() -+ try: -+ voice_session = session.execute( -+ select(VoiceAISessionRow).where(VoiceAISessionRow.call_id == "call_voice_runtime_1") -+ ).scalar_one() -+ assert voice_session.status == "greeting" -+ assert voice_session.disclosure_played_at is None -+ assert voice_session.last_ai_reply_at is None -+ greeting_segment = session.execute( -+ select(VoiceTranscriptSegmentRow) -+ .where(VoiceTranscriptSegmentRow.session_id == voice_session.session_id) -+ .where(VoiceTranscriptSegmentRow.speaker == "assistant") -+ ).scalar_one() -+ assert "ai" not in greeting_segment.text.lower() -+ assert greeting_segment.is_final is False -+ finally: -+ session.close() -+ -+ allow_start.set() -+ deadline = time.time() + 2.0 -+ while time.time() < deadline: -+ session = get_session() -+ try: -+ voice_session = session.execute( -+ select(VoiceAISessionRow).where(VoiceAISessionRow.call_id == "call_voice_runtime_1") -+ ).scalar_one() -+ if voice_session.ai_session_id == "ais_voice_demo": -+ greetings = session.execute( -+ select(VoiceTranscriptSegmentRow) -+ .where(VoiceTranscriptSegmentRow.session_id == voice_session.session_id) -+ .where(VoiceTranscriptSegmentRow.speaker == "assistant") -+ ).scalars().all() -+ assert len(greetings) == 1 -+ break -+ finally: -+ session.close() -+ time.sleep(0.05) -+ else: -+ raise AssertionError("background voice-session start did not finish in time") -+ -+ - def test_mark_reply_delivered_finalizes_only_matching_pending_assistant_segment(): - now = utc_now_iso() - session = get_session() diff --git a/query_prod.py b/query_prod.py deleted file mode 100644 index f8ade01..0000000 --- a/query_prod.py +++ /dev/null @@ -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() diff --git a/scripts/finalize_mvp_pilot.py b/scripts/finalize_mvp_pilot.py deleted file mode 100644 index 0766cfb..0000000 --- a/scripts/finalize_mvp_pilot.py +++ /dev/null @@ -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() diff --git a/scripts/gate1_check.py b/scripts/gate1_check.py deleted file mode 100644 index fe34bb8..0000000 --- a/scripts/gate1_check.py +++ /dev/null @@ -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()) diff --git a/scripts/gate3_check.py b/scripts/gate3_check.py deleted file mode 100644 index 250cc30..0000000 --- a/scripts/gate3_check.py +++ /dev/null @@ -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()) diff --git a/scripts/gate4_check.py b/scripts/gate4_check.py deleted file mode 100644 index 688e30d..0000000 --- a/scripts/gate4_check.py +++ /dev/null @@ -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()) diff --git a/scripts/live_smoke_gate12.py b/scripts/live_smoke_gate12.py deleted file mode 100644 index 2033de0..0000000 --- a/scripts/live_smoke_gate12.py +++ /dev/null @@ -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()) diff --git a/scripts/track10_voice_latency_report.py b/scripts/track10_voice_latency_report.py deleted file mode 100644 index 2ee2764..0000000 --- a/scripts/track10_voice_latency_report.py +++ /dev/null @@ -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()) diff --git a/scripts/track12_live_monitor.py b/scripts/track12_live_monitor.py deleted file mode 100644 index 55ca49b..0000000 --- a/scripts/track12_live_monitor.py +++ /dev/null @@ -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()) diff --git a/scripts/track7_check.py b/scripts/track7_check.py deleted file mode 100644 index 678921e..0000000 --- a/scripts/track7_check.py +++ /dev/null @@ -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", "") - 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", "") - 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() diff --git a/scripts/track7_stage.py b/scripts/track7_stage.py deleted file mode 100644 index 273ccf6..0000000 --- a/scripts/track7_stage.py +++ /dev/null @@ -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()) diff --git a/scripts/track8_check.py b/scripts/track8_check.py deleted file mode 100644 index a2d17d1..0000000 --- a/scripts/track8_check.py +++ /dev/null @@ -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() diff --git a/scripts/uat_dry_run.py b/scripts/uat_dry_run.py deleted file mode 100644 index b0ad30d..0000000 --- a/scripts/uat_dry_run.py +++ /dev/null @@ -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()) diff --git a/scripts/uat_manual_prepare.py b/scripts/uat_manual_prepare.py deleted file mode 100644 index 5512d5b..0000000 --- a/scripts/uat_manual_prepare.py +++ /dev/null @@ -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://: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() diff --git a/scripts/uat_preflight.py b/scripts/uat_preflight.py deleted file mode 100644 index a2c6338..0000000 --- a/scripts/uat_preflight.py +++ /dev/null @@ -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_.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()) diff --git a/services/shared/storage.py b/services/shared/storage.py deleted file mode 100644 index 8d8c8de..0000000 --- a/services/shared/storage.py +++ /dev/null @@ -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) diff --git a/summary_1775499861.0.json b/summary_1775499861.0.json deleted file mode 100644 index ec747fa..0000000 --- a/summary_1775499861.0.json +++ /dev/null @@ -1 +0,0 @@ -null \ No newline at end of file diff --git a/summary_1775500989.2.json b/summary_1775500989.2.json deleted file mode 100644 index 637840f..0000000 --- a/summary_1775500989.2.json +++ /dev/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 - } - ] -} \ No newline at end of file diff --git a/test_get_summary.py b/test_get_summary.py deleted file mode 100644 index 3e2746c..0000000 --- a/test_get_summary.py +++ /dev/null @@ -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() diff --git a/tests/test_manual_uat_scripts.py b/tests/test_manual_uat_scripts.py deleted file mode 100644 index 483e82d..0000000 --- a/tests/test_manual_uat_scripts.py +++ /dev/null @@ -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 diff --git a/ui/supervisor/app.js b/ui/supervisor/app.js index aa05b7d..a654d49 100644 --- a/ui/supervisor/app.js +++ b/ui/supervisor/app.js @@ -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 = ['']; - if (selected !== 'all' && !state.analytics.queueOptions.some((item) => item.queue_id === selected)) { - options.push(``); - } - options.push( - ...state.analytics.queueOptions.map( - (item) => ``, - ), - ); - 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 ` -
-
-
${label}
- ${delta.text} -
-
${formatAnalyticsMetric(metric, value)}
- ${note ? `
${note}
` : ''} -
- `; -} - -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 = '
Нет данных по каналам за выбранный период.
'; - return; - } - - container.innerHTML = ` -
- Канал - Всего - Обработано - Потеряно - Доля ответа -
- ${rows - .map((item) => { - const answerRate = item.total ? (item.answered / item.total) * 100 : 0; - return ` -
- ${escapeHtml(analyticsChannelLabel(item.channel))} - ${formatAnalyticsNumber(item.total, 0)} - ${formatAnalyticsNumber(item.answered, 0)} - ${formatAnalyticsNumber(item.abandoned, 0)} - ${formatAnalyticsNumber(answerRate, 2)}% -
- `; - }) - .join('')} - `; -} - -function renderAnalyticsQueueTable() { - const container = $('analyticsQueueTable'); - const rows = state.analytics.queueRows || []; - if (!rows.length) { - const reason = state.analytics.queueAccessError - ? 'Таблица очередей недоступна для текущей роли: backend не разрешил получить каталог очередей.' - : 'Очереди пока не вернули исторические данные за выбранное окно.'; - container.innerHTML = `
${escapeHtml(reason)}
`; - return; - } - - container.innerHTML = ` -
- Очередь - Всего - Обработано - SL - Потери -
- ${rows - .map((item) => ` - - `) - .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 = ` -
-
-

Что уже есть

-
- ${implemented.length - ? implemented - .map((item) => `${escapeHtml(item.code)} · ${escapeHtml(item.label)}`) - .join('') - : 'Сервис coverage пока не вернул каталог метрик.'} -
-
-
-

Разрезы

-
- ${dimensions.length - ? dimensions.map((item) => `${escapeHtml(item)}`).join('') - : 'Нет данных о разрезах.'} -
-
-
-

Фильтры

-
- ${filters.length - ? filters.map((item) => `${escapeHtml(item)}`).join('') - : 'Нет данных о фильтрах.'} -
-
-
-
-
- Аналитика по агентам -

Появится, когда reporting начнёт отдавать детализацию по agent_id и персональные ряды производительности.

-
-
- AI-аналитика -

Ждём отдельные AI-метрики: долю закрытий, передачу оператору, задержку ответа и покрытие по AI-сводкам.

-
-
- Качество речи -

Блок будет строиться поверх скоринга транскриптов, QA-правил и расширенных метаданных записей.

-
- ${ - state.analytics.queueAccessError - ? `
- Доступ к каталогу очередей -

${escapeHtml(state.analytics.queueAccessError)}

-
` - : '' - } -
- `; -} - -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 = '
Нет данных тренда за выбранный период.
'; - 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 ` - - ${escapeHtml(formatAnalyticsNumber(labelValue, metric === 'volume' ? 0 : 1))} - `; - }) - .join(''); - - const markers = values - .map((value, index) => { - const point = pointFor(value, index); - return ``; - }) - .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 `${escapeHtml(item.label)}`; - }) - .join(''); - - container.innerHTML = ` -
- ${escapeHtml(metric === 'volume' ? 'Volume' : metric)} - ${escapeHtml(formatAnalyticsMetric(metric, values[values.length - 1] || 0))} в последней точке -
- - ${grid} - - ${markers} - ${labels} - - `; -} - -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;