feat(ai): Finalize voice operator persona and voice name flow
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
+1519
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
||||
track9/[0-9]*/
|
||||
track9_1/[0-9]*/
|
||||
track10/[0-9]*/
|
||||
track11/[0-9]*/
|
||||
@@ -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`
|
||||
@@ -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 +0,0 @@
|
||||
[]
|
||||
@@ -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 +0,0 @@
|
||||
[]
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user