feat(ai): Finalize voice operator persona and voice name flow

This commit is contained in:
Yera All
2026-04-06 23:19:24 +05:00
parent cfee88cdb1
commit 1497bb0feb
31 changed files with 2175 additions and 638 deletions
+3
View File
@@ -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
+3
View File
@@ -32,6 +32,8 @@ AI_API_BASE=https://<llm-host>/v1
AI_API_KEY=<llm-api-key>
AI_MODEL=<llm-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
BIN
View File
Binary file not shown.
+1519
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -1,4 +0,0 @@
track9/[0-9]*/
track9_1/[0-9]*/
track10/[0-9]*/
track11/[0-9]*/
-13
View File
@@ -1,13 +0,0 @@
# Track 10 Acceptance Evidence
Store Track 10 reliability evidence per run:
- `docs/acceptance/track10/<timestamp>/...`
Recommended contents:
- `voice-latency-latest.json`
- `command-output.txt`
- `track10-acceptance.md`
Reference runbook:
- `docs/runbooks/track10-live-voice-reliability.md`
@@ -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`
-15
View File
@@ -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/<timestamp>/track11-acceptance.md`
- `docs/acceptance/track11/<timestamp>/operator-live-calls.json`
- `docs/acceptance/track11/<timestamp>/bridge-actions.json`
- `docs/acceptance/track11/<timestamp>/playback-proof.json`
Reference runbook:
- `docs/runbooks/track11-live-operator-call-control.md`
@@ -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.
@@ -1,41 +0,0 @@
# Track 11 Acceptance - <timestamp>
## Environment
- Base URL: `<gateway_base_url>`
- Database: `<database_url>`
- Asterisk VM: `<asterisk_host>`
- Operator account: `<operator_user>`
- Operator extension: `<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: `<decision>`
- Notes:
- `<notes>`
@@ -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"
}
]
}
@@ -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"
}
]
@@ -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"
}
@@ -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`
@@ -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"}
@@ -1,38 +0,0 @@
# Track 12 Acceptance - <timestamp>
## Environment
- Base URL: `<gateway_base_url>`
- Database: `<database_url>`
- Asterisk host: `<asterisk_host>`
- Operator user: `<operator_user>`
- Operator extension: `<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: `<decision>`
- Notes:
- `<notes>`
@@ -1,38 +0,0 @@
# Track 14 Acceptance
## Environment
- Browser: `<chrome|edge version>`
- Gateway base URL: `<url>`
- Asterisk host: `<host>`
- WSS URL: `<wss-url>`
- Operator app user: `<user>`
- Browser SIP auth user: `<sip-username>`
## 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
@@ -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
-27
View File
@@ -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/<timestamp>/...` 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 <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)
@@ -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 <gateway> --check-sftp
python scripts\asterisk_lab_smoke.py --base-url <gateway> --database-url <db> --require-recording
python scripts\track9_check.py --base-url <gateway> --database-url <db> --require-recording
python scripts\track9_collect_evidence.py --base-url <gateway> --database-url <db> --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
@@ -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:
-18
View File
@@ -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.
+116 -30
View File
@@ -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],
*,
@@ -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."
)
+251
View File
@@ -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",
@@ -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(
+8
View File
@@ -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())
+78
View File
@@ -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")
+89
View File
@@ -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()