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