diff --git a/backend/app/services/l1_session_service.py b/backend/app/services/l1_session_service.py index 1d18f4da..4f970623 100644 --- a/backend/app/services/l1_session_service.py +++ b/backend/app/services/l1_session_service.py @@ -9,8 +9,10 @@ from uuid import UUID from sqlalchemy.ext.asyncio import AsyncSession +from app.models.flow_proposal import FlowProposal from app.models.l1_walk_session import L1WalkSession from app.models.user import User +from app.services import internal_ticket_service def _resolve_acting_as(user: User) -> Optional[str]: @@ -149,3 +151,126 @@ async def update_notes( session.last_step_at = datetime.now(timezone.utc) await db.flush() return session + + +async def resolve( + db: AsyncSession, + *, + session_id: UUID, + helpful: bool, + resolution_notes: str, +) -> L1WalkSession: + """Close a session as resolved. + + - Sets status='resolved', helpful, resolution_notes, resolved_at. + - On helpful=True AND session_kind='proposal': flips + flow_proposal.validated_by_outcome=True (one-bit aggregate signal). + - Closes the linked internal ticket (PSA close stubbed for Phase 2). + - Raises ValueError on missing or non-active session. + """ + session = await db.get(L1WalkSession, session_id) + if not session: + raise ValueError(f"L1WalkSession {session_id} not found") + if session.status != "active": + raise ValueError(f"Session not active (status={session.status})") + now = datetime.now(timezone.utc) + session.status = "resolved" + session.helpful = helpful + session.resolution_notes = resolution_notes + session.resolved_at = now + session.last_step_at = now + + if helpful and session.session_kind == "proposal" and session.flow_proposal_id: + proposal = await db.get(FlowProposal, session.flow_proposal_id) + if proposal: + proposal.validated_by_outcome = True + + if session.ticket_kind == "internal": + await internal_ticket_service.update_status( + db, + ticket_id=UUID(session.ticket_id), + status="resolved", + resolution_notes=resolution_notes, + ) + # PSA close deferred to Phase 2 — no-op for now + + await db.flush() + return session + + +async def escalate( + db: AsyncSession, + *, + session_id: UUID, + reason: str, + reason_category: str, +) -> L1WalkSession: + """Escalate an active session to engineering. + + - Sets status='escalated', escalation_reason, escalation_reason_category, resolved_at. + - Marks the linked internal ticket as escalated (PSA reassign deferred to Phase 2). + - Raises ValueError on missing or non-active session. + """ + session = await db.get(L1WalkSession, session_id) + if not session: + raise ValueError(f"L1WalkSession {session_id} not found") + if session.status != "active": + raise ValueError(f"Session not active (status={session.status})") + now = datetime.now(timezone.utc) + session.status = "escalated" + session.escalation_reason = reason + session.escalation_reason_category = reason_category + session.resolved_at = now + session.last_step_at = now + + if session.ticket_kind == "internal": + await internal_ticket_service.update_status( + db, + ticket_id=UUID(session.ticket_id), + status="escalated", + ) + # PSA reassign deferred to Phase 2 + + await db.flush() + return session + + +async def escalate_without_walk( + db: AsyncSession, + *, + account_id: UUID, + user: User, + ticket_id: str, + ticket_kind: str, + reason_category: str, + reason: Optional[str] = None, +) -> L1WalkSession: + """Create an immediately-escalated session with no walked_path. + + Used from the BuildAbortedNoKB screen (no KB content available to walk a + tree). Captures the call as an audit record + escalates the ticket without + requiring a walker session in between. + """ + now = datetime.now(timezone.utc) + session = L1WalkSession( + account_id=account_id, + created_by_user_id=user.id, + acting_as=_resolve_acting_as(user), + ticket_id=ticket_id, + ticket_kind=ticket_kind, + session_kind="adhoc", + status="escalated", + escalation_reason=reason, + escalation_reason_category=reason_category, + resolved_at=now, + last_step_at=now, + ) + db.add(session) + if ticket_kind == "internal": + await internal_ticket_service.update_status( + db, + ticket_id=UUID(ticket_id), + status="escalated", + ) + await db.flush() + return session diff --git a/backend/tests/test_l1_session_service.py b/backend/tests/test_l1_session_service.py index 462e1148..1b3f0191 100644 --- a/backend/tests/test_l1_session_service.py +++ b/backend/tests/test_l1_session_service.py @@ -1,4 +1,4 @@ -"""Tests for l1_session_service start_* functions (T12) and record_step/update_notes (T13).""" +"""Tests for l1_session_service start_* functions (T12), record_step/update_notes (T13), resolve/escalate (T14).""" import uuid import pytest from sqlalchemy.ext.asyncio import AsyncSession @@ -15,7 +15,11 @@ from app.services.l1_session_service import ( _resolve_acting_as, record_step, update_notes, + resolve, + escalate, + escalate_without_walk, ) +from app.services import internal_ticket_service # --------------------------------------------------------------------------- @@ -419,3 +423,353 @@ async def test_update_notes_size_cap(test_db: AsyncSession): notes = [{"timestamp": "2026-05-28T10:00:00Z", "content": big_content}] with pytest.raises(ValueError, match="256KB"): await update_notes(test_db, session_id=session.id, notes=notes) + + +# --------------------------------------------------------------------------- +# T14: resolve, escalate, escalate_without_walk tests +# --------------------------------------------------------------------------- + +async def _make_internal_ticket(db: AsyncSession, *, account_id: uuid.UUID, user_id: uuid.UUID) -> object: + """Create an internal ticket and return it.""" + return await internal_ticket_service.create_ticket( + db, + account_id=account_id, + created_by_user_id=user_id, + problem_statement="Customer cannot log in", + ) + + +@pytest.mark.asyncio +async def test_resolve_proposal_helpful_flips_validated_by_outcome(test_db: AsyncSession): + """resolve(helpful=True) on a proposal session sets validated_by_outcome=True.""" + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + ticket = await _make_internal_ticket(test_db, account_id=account.id, user_id=l1.id) + ai_session = await _make_ai_session(test_db, user_id=l1.id, account_id=account.id) + proposal = await _make_proposal(test_db, account_id=account.id, source_session_id=ai_session.id) + + session = await start_proposal_session( + test_db, + account_id=account.id, + user=l1, + flow_proposal_id=proposal.id, + ticket_id=str(ticket.id), + ticket_kind="internal", + ) + resolved = await resolve( + test_db, + session_id=session.id, + helpful=True, + resolution_notes="Issue fixed by proposal walk", + ) + assert resolved.status == "resolved" + assert resolved.helpful is True + assert resolved.resolution_notes == "Issue fixed by proposal walk" + assert resolved.resolved_at is not None + + # Proposal should now be validated + await test_db.refresh(proposal) + assert proposal.validated_by_outcome is True + + # Internal ticket should be closed + await test_db.refresh(ticket) + assert ticket.status == "resolved" + + +@pytest.mark.asyncio +async def test_resolve_proposal_not_helpful_leaves_validated_by_outcome_false(test_db: AsyncSession): + """resolve(helpful=False) on a proposal session does NOT flip validated_by_outcome.""" + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + ticket = await _make_internal_ticket(test_db, account_id=account.id, user_id=l1.id) + ai_session = await _make_ai_session(test_db, user_id=l1.id, account_id=account.id) + proposal = await _make_proposal(test_db, account_id=account.id, source_session_id=ai_session.id) + + session = await start_proposal_session( + test_db, + account_id=account.id, + user=l1, + flow_proposal_id=proposal.id, + ticket_id=str(ticket.id), + ticket_kind="internal", + ) + resolved = await resolve( + test_db, + session_id=session.id, + helpful=False, + resolution_notes="Proposal did not help", + ) + assert resolved.helpful is False + await test_db.refresh(proposal) + assert proposal.validated_by_outcome is False + + +@pytest.mark.asyncio +async def test_resolve_flow_session_closes_ticket_no_proposal_update(test_db: AsyncSession): + """resolve on a flow session closes the ticket and does not touch proposals.""" + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + ticket = await _make_internal_ticket(test_db, account_id=account.id, user_id=l1.id) + tree = await _make_tree(test_db, account_id=account.id, author_id=l1.id) + + session = await start_flow_session( + test_db, + account_id=account.id, + user=l1, + flow_id=tree.id, + ticket_id=str(ticket.id), + ticket_kind="internal", + ) + resolved = await resolve( + test_db, + session_id=session.id, + helpful=True, + resolution_notes="Flow resolved the issue", + ) + assert resolved.status == "resolved" + assert resolved.session_kind == "flow" + assert resolved.flow_proposal_id is None + + await test_db.refresh(ticket) + assert ticket.status == "resolved" + + +@pytest.mark.asyncio +async def test_resolve_adhoc_session_closes_ticket(test_db: AsyncSession): + """resolve on an adhoc session closes the ticket with no proposal interaction.""" + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + ticket = await _make_internal_ticket(test_db, account_id=account.id, user_id=l1.id) + + session = await start_adhoc_session( + test_db, + account_id=account.id, + user=l1, + ticket_id=str(ticket.id), + ticket_kind="internal", + ) + resolved = await resolve( + test_db, + session_id=session.id, + helpful=True, + resolution_notes="Adhoc resolved", + ) + assert resolved.status == "resolved" + await test_db.refresh(ticket) + assert ticket.status == "resolved" + + +@pytest.mark.asyncio +async def test_resolve_psa_session_no_ticket_update(test_db: AsyncSession): + """resolve on a PSA-backed session does not attempt to update an internal ticket.""" + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + tree = await _make_tree(test_db, account_id=account.id, author_id=l1.id) + + session = await start_flow_session( + test_db, + account_id=account.id, + user=l1, + flow_id=tree.id, + ticket_id="psa-external-id-123", + ticket_kind="psa", + ) + resolved = await resolve( + test_db, + session_id=session.id, + helpful=True, + resolution_notes="PSA ticket resolved externally", + ) + assert resolved.status == "resolved" + assert resolved.ticket_kind == "psa" + + +@pytest.mark.asyncio +async def test_resolve_raises_on_missing_session(test_db: AsyncSession): + """resolve raises ValueError when session does not exist.""" + with pytest.raises(ValueError, match="not found"): + await resolve( + test_db, + session_id=uuid.uuid4(), + helpful=True, + resolution_notes="N/A", + ) + + +@pytest.mark.asyncio +async def test_resolve_raises_on_inactive_session(test_db: AsyncSession): + """resolve raises ValueError when session is not active.""" + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + ticket = await _make_internal_ticket(test_db, account_id=account.id, user_id=l1.id) + + session = await start_adhoc_session( + test_db, + account_id=account.id, + user=l1, + ticket_id=str(ticket.id), + ticket_kind="internal", + ) + session.status = "escalated" + await test_db.flush() + + with pytest.raises(ValueError, match="not active"): + await resolve( + test_db, + session_id=session.id, + helpful=False, + resolution_notes="N/A", + ) + + +@pytest.mark.asyncio +async def test_escalate_marks_session_and_ticket_as_escalated(test_db: AsyncSession): + """escalate sets session status=escalated and closes the internal ticket as escalated.""" + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + ticket = await _make_internal_ticket(test_db, account_id=account.id, user_id=l1.id) + tree = await _make_tree(test_db, account_id=account.id, author_id=l1.id) + + session = await start_flow_session( + test_db, + account_id=account.id, + user=l1, + flow_id=tree.id, + ticket_id=str(ticket.id), + ticket_kind="internal", + ) + escalated = await escalate( + test_db, + session_id=session.id, + reason="Customer reported intermittent failure not covered by flow", + reason_category="out_of_scope", + ) + assert escalated.status == "escalated" + assert escalated.escalation_reason == "Customer reported intermittent failure not covered by flow" + assert escalated.escalation_reason_category == "out_of_scope" + assert escalated.resolved_at is not None + assert escalated.last_step_at is not None + + await test_db.refresh(ticket) + assert ticket.status == "escalated" + + +@pytest.mark.asyncio +async def test_escalate_raises_on_missing_session(test_db: AsyncSession): + """escalate raises ValueError when session does not exist.""" + with pytest.raises(ValueError, match="not found"): + await escalate( + test_db, + session_id=uuid.uuid4(), + reason="Some reason", + reason_category="unknown", + ) + + +@pytest.mark.asyncio +async def test_escalate_raises_on_inactive_session(test_db: AsyncSession): + """escalate raises ValueError when session is already inactive.""" + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + ticket = await _make_internal_ticket(test_db, account_id=account.id, user_id=l1.id) + + session = await start_adhoc_session( + test_db, + account_id=account.id, + user=l1, + ticket_id=str(ticket.id), + ticket_kind="internal", + ) + session.status = "resolved" + await test_db.flush() + + with pytest.raises(ValueError, match="not active"): + await escalate( + test_db, + session_id=session.id, + reason="Too late", + reason_category="other", + ) + + +@pytest.mark.asyncio +async def test_escalate_without_walk_creates_escalated_adhoc_session(test_db: AsyncSession): + """escalate_without_walk creates an immediately-escalated session with empty walked_path.""" + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + ticket = await _make_internal_ticket(test_db, account_id=account.id, user_id=l1.id) + + session = await escalate_without_walk( + test_db, + account_id=account.id, + user=l1, + ticket_id=str(ticket.id), + ticket_kind="internal", + reason_category="no_kb_content", + reason="No KB article matched this issue", + ) + assert session.status == "escalated" + assert session.session_kind == "adhoc" + assert session.walked_path == [] + assert session.escalation_reason == "No KB article matched this issue" + assert session.escalation_reason_category == "no_kb_content" + assert session.resolved_at is not None + assert session.last_step_at is not None + assert session.account_id == account.id + assert session.created_by_user_id == l1.id + + +@pytest.mark.asyncio +async def test_escalate_without_walk_escalates_internal_ticket(test_db: AsyncSession): + """escalate_without_walk marks the internal ticket as escalated.""" + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + ticket = await _make_internal_ticket(test_db, account_id=account.id, user_id=l1.id) + + await escalate_without_walk( + test_db, + account_id=account.id, + user=l1, + ticket_id=str(ticket.id), + ticket_kind="internal", + reason_category="no_kb_content", + ) + await test_db.refresh(ticket) + assert ticket.status == "escalated" + + +@pytest.mark.asyncio +async def test_escalate_without_walk_psa_does_not_touch_internal_ticket(test_db: AsyncSession): + """escalate_without_walk with ticket_kind='psa' does not update internal tickets.""" + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + + session = await escalate_without_walk( + test_db, + account_id=account.id, + user=l1, + ticket_id="psa-ticket-999", + ticket_kind="psa", + reason_category="no_kb_content", + ) + assert session.status == "escalated" + assert session.ticket_kind == "psa" + + +@pytest.mark.asyncio +async def test_escalate_without_walk_reason_is_optional(test_db: AsyncSession): + """escalate_without_walk works without a reason string.""" + account = await _make_account(test_db) + l1 = await _make_user(test_db, account_id=account.id) + ticket = await _make_internal_ticket(test_db, account_id=account.id, user_id=l1.id) + + session = await escalate_without_walk( + test_db, + account_id=account.id, + user=l1, + ticket_id=str(ticket.id), + ticket_kind="internal", + reason_category="no_kb_content", + ) + assert session.escalation_reason is None + assert session.escalation_reason_category == "no_kb_content"