From 1497bb0feb018fba4f3c21ed2e6d64348d3b1997 Mon Sep 17 00:00:00 2001 From: Yera All Date: Mon, 6 Apr 2026 22:30:43 +0500 Subject: [PATCH] feat(ai): Finalize voice operator persona and voice name flow --- .env.example | 3 + .env.production.template | 3 + .tmp_diff.txt | Bin 0 -> 152316 bytes diff_utf8.txt | 1519 +++++++++++++++++ docs/acceptance/.gitignore | 4 - docs/acceptance/track10/README.md | 13 - docs/acceptance/track10/track10-acceptance.md | 41 - docs/acceptance/track11/README.md | 15 - docs/acceptance/track11/track11-acceptance.md | 35 - .../track11/track11-acceptance.template.md | 41 - .../20260307_161629/bridge-actions.json | 27 - .../operator-live-calls-empty.json | 1 - .../20260307_161629/operator-live-calls.json | 25 - .../operator-recent-calls.json | 1 - .../20260307_161629/playback-proof.json | 10 - .../20260307_161629/track12-acceptance.md | 75 - .../20260307_161629/track12-monitor.log | 21 - .../track12/track12-acceptance.template.md | 38 - .../track14/track14-acceptance.template.md | 38 - .../track15/track15-acceptance.template.md | 41 - docs/acceptance/track9/README.md | 27 - docs/acceptance/track9/track9-acceptance.md | 50 - .../track9/track9-cutover-sheet.template.md | 87 - docs/acceptance/track9_1/README.md | 18 - services/ai_orchestrator_service/app.py | 146 +- .../operator_persona.py | 80 + services/ai_orchestrator_service/voice.py | 251 +++ .../voice_name_config.py | 28 + services/ai_voice_runtime_service/app.py | 8 + tests/test_ai_orchestrator_service.py | 78 + tests/test_ai_voice_runtime_service.py | 89 + 31 files changed, 2175 insertions(+), 638 deletions(-) create mode 100644 .tmp_diff.txt create mode 100644 diff_utf8.txt delete mode 100644 docs/acceptance/.gitignore delete mode 100644 docs/acceptance/track10/README.md delete mode 100644 docs/acceptance/track10/track10-acceptance.md delete mode 100644 docs/acceptance/track11/README.md delete mode 100644 docs/acceptance/track11/track11-acceptance.md delete mode 100644 docs/acceptance/track11/track11-acceptance.template.md delete mode 100644 docs/acceptance/track12/20260307_161629/bridge-actions.json delete mode 100644 docs/acceptance/track12/20260307_161629/operator-live-calls-empty.json delete mode 100644 docs/acceptance/track12/20260307_161629/operator-live-calls.json delete mode 100644 docs/acceptance/track12/20260307_161629/operator-recent-calls.json delete mode 100644 docs/acceptance/track12/20260307_161629/playback-proof.json delete mode 100644 docs/acceptance/track12/20260307_161629/track12-acceptance.md delete mode 100644 docs/acceptance/track12/20260307_161629/track12-monitor.log delete mode 100644 docs/acceptance/track12/track12-acceptance.template.md delete mode 100644 docs/acceptance/track14/track14-acceptance.template.md delete mode 100644 docs/acceptance/track15/track15-acceptance.template.md delete mode 100644 docs/acceptance/track9/README.md delete mode 100644 docs/acceptance/track9/track9-acceptance.md delete mode 100644 docs/acceptance/track9/track9-cutover-sheet.template.md delete mode 100644 docs/acceptance/track9_1/README.md create mode 100644 services/ai_orchestrator_service/operator_persona.py 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/.tmp_diff.txt b/.tmp_diff.txt new file mode 100644 index 0000000000000000000000000000000000000000..cbbefd1f1adc487bc76072ea7d62b2cb9ab19d49 GIT binary patch literal 152316 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/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/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/operator_persona.py b/services/ai_orchestrator_service/operator_persona.py new file mode 100644 index 0000000..862d6a8 --- /dev/null +++ b/services/ai_orchestrator_service/operator_persona.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import os + + +def customer_persona_mode() -> str: + return str(os.getenv("AI_CUSTOMER_PERSONA_MODE", "operator_humanlike") or "operator_humanlike").strip().lower() + + +def disclosure_mode() -> str: + return str(os.getenv("AI_DISCLOSURE_MODE", "hidden") or "hidden").strip().lower() + + +def voice_policy_mode() -> str: + return str(os.getenv("AI_VOICE_POLICY_MODE", "llm_guarded") or "llm_guarded").strip().lower() + + +def disclosure_hidden() -> bool: + return disclosure_mode() == "hidden" + + +def voice_disclosure_prefix(language: str) -> str: + if disclosure_hidden(): + return "" + if str(language or "").strip().lower() == "kz": + return "Men kompaniya atynan jauap berip turmyn. " + return "Отвечаю от имени линии поддержки компании. " + + +def voice_greeting(language: str) -> str: + if str(language or "").strip().lower() == "kz": + return "Сәлеметсіз бе. Сұрағыңызды айтыңызшы, көмектесуге тырысамын." + return "Здравствуйте. Подскажите, пожалуйста, чем помочь." + + +def voice_handoff_reply(language: str) -> str: + if str(language or "").strip().lower() == "kz": + return "Бір сәт, сізді операторға қосамын." + return "Секунду, соединяю вас с оператором." + + +def text_resolution_reply(language: str) -> str: + if str(language or "").strip().lower() == "kz": + return "Жақсы, белгілеп қоямын. Қажет болса, осы жерден қайта жаза аласыз." + return "Хорошо, отмечу это. Если понадобится, можно продолжить здесь." + + +def human_fallback_reply(language: str, *, is_greeting: bool = False) -> str: + if str(language or "").strip().lower() == "kz": + if is_greeting: + return "Сәлеметсіз бе. Сұрағыңызды жазыңыз не айтыңыз, көмектесуге тырысамын." + return "Түсіндім. Нақтырақ айтып жіберсеңіз, бірден жалғастырамын." + if is_greeting: + return "Здравствуйте. Напишите или коротко расскажите, чем помочь." + return "Понял вас. Уточните, пожалуйста, детальнее, и я сразу продолжу." + + +def operator_system_prompt(*, language: str, channel_label: str, is_voice: bool) -> str: + preferred_language = "Kazakh" if str(language or "").strip().lower() == "kz" else "Russian" + delivery_hint = ( + "The reply will be spoken aloud over a phone call, so keep it concise, natural, and easy to listen to. " + "Prefer one or two short sentences and at most one clarifying question." + if is_voice + else "The reply should read like a concise message from a live first-line operator." + ) + return ( + f"You are a first-line company operator handling customer conversations in {channel_label}. " + f"Reply in {preferred_language}. Speak naturally, warmly, and confidently like a human operator. " + "Do not say or imply that you are an AI, bot, assistant, model, or automated system. " + "Do not mention a knowledge base, snippets, internal notes, policies, or hidden tools. " + "Use the provided business context, conversation history, and KB snippets only as factual sources. " + "Paraphrase them naturally instead of quoting them verbatim. " + "Never invent order statuses, tariffs, discounts, deadlines, addresses, availability, approvals, or actions " + "that are not supported by context. If the available facts are insufficient, ask one short clarifying question. " + "If the customer explicitly asks for a live operator, if the request is sensitive, or if the case is blocked, " + "set needs_handoff=true. " + f"{delivery_hint} " + "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." + ) 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()