ec397f2c65
v0.5 (Live Assist — on-the-job voice companion) milestone complete. 4 phases: P0 (pre-execution, v0.1.10) → P1 (assist core + guardrail, v0.1.11) → P2 (integration + tech-debt + NFR, v0.1.12) → P3 (final review + ship, v0.1.13 = milestone release). 16/16 REQs covered (3 ASSIST + 4 NFR + 9 IDEATE). 4 v0.6 backlog. 469 tests passed, 0 failed. 1 P0 fixed (guardrail processor safety). 8 P1+ flagged for v0.6. 8 v0.4 P1+ tech-debt addressed. G-049 + G-067 grill MUSTs resolved. ESCALATION-01 (PIPEDA) OPEN for human legal review before assist surface go-live. ---ci--- project: praxis phase: 3 milestone: v0.5 status: complete requirements: covered: [REQ-ASSIST-01, REQ-ASSIST-02, REQ-ASSIST-03, REQ-NFR-ASSIST-01, REQ-NFR-ASSIST-02, REQ-NFR-ASSIST-03, REQ-NFR-ASSIST-04, REQ-IDEATE-01, REQ-IDEATE-02, REQ-IDEATE-03, REQ-IDEATE-04, REQ-IDEATE-05, REQ-IDEATE-06, REQ-IDEATE-07, REQ-IDEATE-08, REQ-IDEATE-09] partial: [] ---/ci---
171 lines
6.7 KiB
Python
171 lines
6.7 KiB
Python
"""P1 integration test — guardrail e2e through the assist pipeline (TASK-08-03).
|
|
|
|
Verifies REQ-ASSIST-03 (the guardrail works in the pipeline, not just standalone):
|
|
1. Start a shift.
|
|
2. Mock an LLM response that gives a direct answer → guardrail blocks it +
|
|
canned fallback is sent to TTS.
|
|
3. The turn's guardrail_verdict_json has allowed=False, category='blocked_direct_script'.
|
|
4. guardrail_block_count is incremented.
|
|
5. Mock an LLM response that gives a coaching question → allowed + sent to TTS.
|
|
6. The turn's guardrail_verdict_json has allowed=True, category='coaching'.
|
|
7. Incremental audit-log: the partial turn (ASR only) is written before the
|
|
LLM response, then updated with the LLM response + verdict (REQ-IDEATE-09).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from db.migrate import apply_migrations
|
|
from db.store import PraxisStore, HARDCODED_LEARNER_ID
|
|
from server.assist.context import AssistContext, COACHING_INSTRUCTION
|
|
from server.assist.guardrail_processor import LiveAssistGuardrailProcessor
|
|
from server.assist.routes import router as assist_router
|
|
from server.assist.session import AssistSession
|
|
from server.guardrails.live_assist import CANNED_FALLBACK, LiveAssistGuardrail
|
|
from server.services.base import GuardrailContext
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path: Path) -> PraxisStore:
|
|
db = tmp_path / "test_p1_guardrail_e2e.db"
|
|
apply_migrations(db)
|
|
s = PraxisStore(db)
|
|
asyncio.run(s.init())
|
|
return s
|
|
|
|
|
|
def _ctx() -> AssistContext:
|
|
return AssistContext(
|
|
system_prompt=f"{COACHING_INSTRUCTION}\n\nWeek 1, damaged-product refund.\n\nBe brief.",
|
|
current_week=1,
|
|
scenario_tag="damaged-product refund",
|
|
theta=0.0,
|
|
coaching_focus="empathy",
|
|
path_slug="customer_service",
|
|
)
|
|
|
|
|
|
def test_guardrail_blocks_direct_answer_e2e(store: PraxisStore):
|
|
"""A direct-answer LLM response is blocked + canned fallback is emitted (TASK-08-03)."""
|
|
session = AssistSession(store, HARDCODED_LEARNER_ID, _ctx())
|
|
asyncio.run(session.start())
|
|
|
|
# Simulate the in-loop guardrail processor on a direct-answer LLM response.
|
|
proc = LiveAssistGuardrailProcessor(
|
|
guardrail=LiveAssistGuardrail(), session=session, llm_context=None
|
|
)
|
|
proc.push_frame = AsyncMock()
|
|
|
|
async def _run():
|
|
from pipecat.frames.frames import LLMFullResponseEndFrame, TextFrame, TranscriptionFrame
|
|
|
|
# ASR transcript (partial turn — REQ-IDEATE-09).
|
|
await proc.process_frame(
|
|
TranscriptionFrame(text="Customer wants a refund", user_id="u", timestamp=""),
|
|
direction=1,
|
|
)
|
|
# LLM response: direct answer.
|
|
await proc.process_frame(TextFrame(text="You should say sorry to the customer."), direction=1)
|
|
await proc.process_frame(LLMFullResponseEndFrame(), direction=1)
|
|
|
|
asyncio.run(_run())
|
|
|
|
# The block count was incremented.
|
|
assert session.guardrail_block_count == 1
|
|
# The canned fallback was emitted (pushed as a TextFrame).
|
|
pushed_texts = [
|
|
call.args[0].text for call in proc.push_frame.await_args_list
|
|
if hasattr(call.args[0], "text")
|
|
]
|
|
assert CANNED_FALLBACK in pushed_texts
|
|
# The turn's guardrail_verdict_json has allowed=False.
|
|
turns = asyncio.run(store.get_turns(session.session_id))
|
|
assert len(turns) == 1
|
|
verdict = json.loads(turns[0].guardrail_verdict_json)
|
|
assert verdict["allowed"] is False
|
|
assert verdict["category"] == "blocked_direct_script"
|
|
|
|
|
|
def test_guardrail_allows_coaching_question_e2e(store: PraxisStore):
|
|
"""A coaching-question LLM response is allowed + sent to TTS (TASK-08-03)."""
|
|
session = AssistSession(store, HARDCODED_LEARNER_ID, _ctx())
|
|
asyncio.run(session.start())
|
|
|
|
proc = LiveAssistGuardrailProcessor(
|
|
guardrail=LiveAssistGuardrail(), session=session, llm_context=None
|
|
)
|
|
proc.push_frame = AsyncMock()
|
|
|
|
async def _run():
|
|
from pipecat.frames.frames import LLMFullResponseEndFrame, TextFrame, TranscriptionFrame
|
|
|
|
await proc.process_frame(
|
|
TranscriptionFrame(text="Customer is upset", user_id="u", timestamp=""),
|
|
direction=1,
|
|
)
|
|
await proc.process_frame(TextFrame(text="What do you think the customer needs?"), direction=1)
|
|
await proc.process_frame(LLMFullResponseEndFrame(), direction=1)
|
|
|
|
asyncio.run(_run())
|
|
|
|
# No block.
|
|
assert session.guardrail_block_count == 0
|
|
# The turn's guardrail_verdict_json has allowed=True, category='coaching'.
|
|
turns = asyncio.run(store.get_turns(session.session_id))
|
|
assert len(turns) == 1
|
|
verdict = json.loads(turns[0].guardrail_verdict_json)
|
|
assert verdict["allowed"] is True
|
|
assert verdict["category"] == "coaching"
|
|
|
|
|
|
def test_incremental_audit_log_partial_then_complete(store: PraxisStore):
|
|
"""REQ-IDEATE-09: partial turn (ASR) written before LLM response, then updated with verdict."""
|
|
session = AssistSession(store, HARDCODED_LEARNER_ID, _ctx())
|
|
asyncio.run(session.start())
|
|
|
|
proc = LiveAssistGuardrailProcessor(
|
|
guardrail=LiveAssistGuardrail(), session=session, llm_context=None
|
|
)
|
|
proc.push_frame = AsyncMock()
|
|
|
|
async def _run_partial_only():
|
|
from pipecat.frames.frames import TranscriptionFrame
|
|
|
|
# ASR arrives but the LLM never responds (simulated abrupt termination).
|
|
await proc.process_frame(
|
|
TranscriptionFrame(text="Customer is upset", user_id="u", timestamp=""),
|
|
direction=1,
|
|
)
|
|
|
|
asyncio.run(_run_partial_only())
|
|
# The partial turn (ASR only) is in the turns table with tts_text NULL.
|
|
turns = asyncio.run(store.get_turns(session.session_id))
|
|
assert len(turns) == 1
|
|
assert turns[0].asr_text == "Customer is upset"
|
|
assert turns[0].tts_text is None
|
|
assert turns[0].guardrail_verdict_json is None
|
|
|
|
# Now simulate the LLM response arriving (the turn is completed).
|
|
async def _run_complete():
|
|
from pipecat.frames.frames import LLMFullResponseEndFrame, TextFrame
|
|
|
|
await proc.process_frame(TextFrame(text="How could you acknowledge their frustration?"), direction=1)
|
|
await proc.process_frame(LLMFullResponseEndFrame(), direction=1)
|
|
|
|
asyncio.run(_run_complete())
|
|
turns = asyncio.run(store.get_turns(session.session_id))
|
|
# The partial turn was updated (not a new row).
|
|
assert len(turns) == 1
|
|
assert turns[0].tts_text is not None
|
|
assert turns[0].guardrail_verdict_json is not None
|
|
verdict = json.loads(turns[0].guardrail_verdict_json)
|
|
assert verdict["allowed"] is True
|
|
assert verdict["category"] == "coaching" |