Engineer applies a fix but can't verify yet (waiting on client power-cycle,
AD replication, async sync). Today the verifying banner forces a synchronous
verdict (worked / didn't / partial) — anything else means leaving the banner
stale or guessing wrong. This adds a fourth outcome that parks the fix in a
non-terminal "Awaiting verification" state with a reason ("waiting on what?")
and exposes it on the chat-anchored banner so the engineer doesn't lose track.
Backend
- New non-terminal status `applied_pending` parallel to `applied_partial`.
- New `pending_reason` column (nullable Text) — the "what are you waiting on?"
prose, mirrors `partial_notes`. Required when outcome=applied_pending.
- Outcome endpoint allows pending in/out transitions; pending stamps
applied_at but NOT verified_at (it's parked, not verified).
- Resolution-note + escalation-package prompts handle the new status:
resolution note frames the fix as provisional; escalation package surfaces
pending verification as the leading hypothesis with reference to what's
being waited on.
- Migration: add column + extend status CHECK constraint.
Frontend
- New `BannerMode = 'pending'` + `PendingBanner` component (info-tone,
parallel to PartialBanner) with worked / didn't / update-reason actions.
- VerifyingBanner overflow menu adds "Waiting to verify…".
- Nudge banner's "Still checking" button now actually records pending with
a reason, instead of just silencing for the session.
- AssistantChatPage banner-mode derivation maps applied_pending → 'pending'.
Tests: 4 new integration tests covering pending notes requirement, reason
storage + applied_at/verified_at semantics, pending→success transition,
and pending_reason update on re-PATCH.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
769 lines
30 KiB
Python
769 lines
30 KiB
Python
"""Suggested-fix + resolution-note / escalation-package preview-and-post endpoints.
|
|
|
|
Phase 3: active suggested fix lookup + decision recording, resolution-note
|
|
preview with state_version cache.
|
|
|
|
Phase 4: resolution-note POST (writeback to PSA + mark resolved), escalation
|
|
package preview + POST (writeback + mark escalated). Local-only path when
|
|
the session has no linked PSA ticket: markdown is stored on the session and
|
|
the status flipped, no external call.
|
|
|
|
Per FLOWPILOT-MIGRATION.md Sections 5.2 + 5.4.
|
|
"""
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Annotated
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy import select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
|
|
from app.models.ai_session import AISession
|
|
from app.models.session_suggested_fix import SessionSuggestedFix
|
|
from app.models.user import User
|
|
from app.schemas.session_suggested_fix import (
|
|
EscalationPackagePostRequest,
|
|
ResolutionNotePostRequest,
|
|
ResolutionNotePreviewResponse,
|
|
ResolutionPostResponse,
|
|
SessionSuggestedFixDecisionRequest,
|
|
SessionSuggestedFixDecisionResponse,
|
|
SessionSuggestedFixOutcomeRequest,
|
|
SessionSuggestedFixResponse,
|
|
SessionSuggestedFixScriptRequest,
|
|
)
|
|
from app.models.draft_template import DraftTemplate
|
|
from app.models.session_fact import SessionFact
|
|
from app.services.escalation_package_generator import EscalationPackageGeneratorService
|
|
from app.services.preview_cache import preview_cache
|
|
from app.services.psa_writeback_service import (
|
|
PSAStatusVerificationError,
|
|
PSAWritebackService,
|
|
)
|
|
from app.services.resolution_note_generator import ResolutionNoteGeneratorService
|
|
from app.services.template_extraction_service import extract_parameters as _extract_template_parameters
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/ai-sessions/{session_id}", tags=["session-suggested-fixes"])
|
|
|
|
|
|
async def _load_session_or_404(db: AsyncSession, session_id: UUID) -> AISession:
|
|
"""RLS-scoped session load. 404 covers both missing and cross-tenant."""
|
|
result = await db.execute(select(AISession).where(AISession.id == session_id))
|
|
session = result.scalar_one_or_none()
|
|
if session is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
|
return session
|
|
|
|
|
|
# ── Suggested fix: active ──────────────────────────────────────────────────
|
|
|
|
@router.get(
|
|
"/suggested-fixes/active",
|
|
response_model=SessionSuggestedFixResponse,
|
|
)
|
|
async def get_active_suggested_fix(
|
|
session_id: UUID,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
) -> SessionSuggestedFixResponse:
|
|
"""Return the current active suggested fix (`superseded_at IS NULL`) or 404.
|
|
|
|
A session has at most one active fix. Multiple historical rows persist
|
|
for audit, but only the most-recent un-superseded one is returned here.
|
|
"""
|
|
await _load_session_or_404(db, session_id)
|
|
result = await db.execute(
|
|
select(SessionSuggestedFix)
|
|
.where(
|
|
SessionSuggestedFix.session_id == session_id,
|
|
SessionSuggestedFix.superseded_at.is_(None),
|
|
)
|
|
.order_by(SessionSuggestedFix.created_at.desc())
|
|
)
|
|
fix = result.scalars().first()
|
|
if fix is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="No active suggested fix for this session",
|
|
)
|
|
return SessionSuggestedFixResponse.model_validate(fix)
|
|
|
|
|
|
# ── Suggested fix: decision ────────────────────────────────────────────────
|
|
|
|
@router.post(
|
|
"/suggested-fixes/{fix_id}/decision",
|
|
response_model=SessionSuggestedFixDecisionResponse,
|
|
)
|
|
async def record_decision(
|
|
session_id: UUID,
|
|
fix_id: UUID,
|
|
body: SessionSuggestedFixDecisionRequest,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
) -> SessionSuggestedFixDecisionResponse:
|
|
"""Record the engineer's path choice on a suggested fix.
|
|
|
|
Phase 3 recorded the choice and (for `dismissed`) superseded the fix.
|
|
Phase 5 adds side effects: one_off / draft_template return the rendered
|
|
script; draft_template also creates a `draft_templates` row via the
|
|
TemplateExtractionService; build_template returns a redirect to the
|
|
Script Builder.
|
|
"""
|
|
session_obj = await _load_session_or_404(db, session_id)
|
|
|
|
result = await db.execute(
|
|
select(SessionSuggestedFix).where(
|
|
SessionSuggestedFix.id == fix_id,
|
|
SessionSuggestedFix.session_id == session_id,
|
|
)
|
|
)
|
|
fix = result.scalar_one_or_none()
|
|
if fix is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found"
|
|
)
|
|
|
|
# Once a fix has been superseded we still record the engineer's
|
|
# decision (it's a historical signal — "engineer dismissed the
|
|
# interim hypothesis"), but `dismissed` on a superseded row would
|
|
# be redundant noise.
|
|
if fix.superseded_at is not None and body.decision == "dismissed":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="This fix is already superseded by a newer suggestion",
|
|
)
|
|
|
|
fix.user_decision = body.decision
|
|
if body.decision == "dismissed" and fix.superseded_at is None:
|
|
fix.superseded_at = datetime.now(timezone.utc)
|
|
|
|
# Engineer's choice changes the bundle the resolution-note preview sees,
|
|
# so bump state_version too.
|
|
await db.execute(
|
|
update(AISession)
|
|
.where(AISession.id == session_id)
|
|
.values(state_version=AISession.state_version + 1)
|
|
)
|
|
|
|
rendered_script: str | None = None
|
|
draft_template_id: UUID | None = None
|
|
redirect_path: str | None = None
|
|
|
|
# Phase 5 side effects. All three non-dismiss paths assume the fix has
|
|
# either a script_template_id (template match — use the dedicated
|
|
# /scripts/generate endpoint from the frontend, not this one) or an
|
|
# ai_drafted_script (custom script — this is the entry point).
|
|
if body.decision in ("one_off", "draft_template", "build_template"):
|
|
drafted = body.edited_script or fix.ai_drafted_script
|
|
if not drafted:
|
|
# Template-matched fixes take the regular /scripts/generate path.
|
|
# If a fix somehow reaches here without a drafted script AND
|
|
# without a template, that's a client-side wiring bug.
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"Suggested fix has no ai_drafted_script — use "
|
|
"/api/v1/scripts/generate for template-matched fixes."
|
|
),
|
|
)
|
|
rendered_script = drafted.strip()
|
|
|
|
if body.decision == "draft_template":
|
|
# TemplateExtractionService proposes the parameterization. Runs
|
|
# under the same transaction so a failure rolls back the decision.
|
|
session_ctx = await _summarize_session_for_extraction(db, session_id)
|
|
extraction = await _extract_template_parameters(
|
|
script_body=rendered_script or "",
|
|
session_context=session_ctx,
|
|
ticket_context=None, # ticket context wiring lands in Phase 5 polish
|
|
)
|
|
|
|
draft = DraftTemplate(
|
|
account_id=session_obj.account_id,
|
|
source_session_id=session_obj.id,
|
|
source_user_id=current_user.id,
|
|
script_body=extraction["templated_body"] or (rendered_script or ""),
|
|
proposed_parameters={"parameters": extraction["parameters"]},
|
|
proposed_name=fix.title[:200] if fix.title else None,
|
|
status="pending",
|
|
)
|
|
db.add(draft)
|
|
await db.flush()
|
|
draft_template_id = draft.id
|
|
|
|
if body.decision == "build_template":
|
|
# Frontend navigates to the Script Builder preloaded with the
|
|
# drafted body. The builder wires the full parameterization flow;
|
|
# we hand it a scratch-pad query string, not persistent state.
|
|
redirect_path = (
|
|
f"/scripts/builder?from_session={session_obj.id}&fix={fix.id}"
|
|
)
|
|
|
|
await db.commit()
|
|
await db.refresh(fix)
|
|
|
|
return SessionSuggestedFixDecisionResponse(
|
|
id=fix.id,
|
|
user_decision=fix.user_decision, # type: ignore[arg-type]
|
|
rendered_script=rendered_script,
|
|
draft_template_id=draft_template_id,
|
|
redirect_path=redirect_path,
|
|
)
|
|
|
|
|
|
# ── Suggested fix: apply (stamp applied_at) ──────────────────────────────
|
|
|
|
@router.post(
|
|
"/suggested-fixes/{fix_id}/apply",
|
|
response_model=SessionSuggestedFixResponse,
|
|
)
|
|
async def apply_suggested_fix(
|
|
session_id: UUID,
|
|
fix_id: UUID,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
) -> SessionSuggestedFixResponse:
|
|
"""Stamp applied_at when the engineer clicks Apply in the ProposalBanner.
|
|
|
|
This does NOT change status (fix remains 'proposed'). Status only flips
|
|
when the engineer records an outcome via PATCH /outcome.
|
|
|
|
Rules:
|
|
- Fix must be in 'proposed' status; any other status → 409.
|
|
- Idempotent: if applied_at is already set, returns 200 with the unchanged row.
|
|
- Bumps ai_sessions.state_version so resolve/escalate preview generators
|
|
know the fix has entered the verifying phase.
|
|
"""
|
|
await _load_session_or_404(db, session_id)
|
|
|
|
result = await db.execute(
|
|
select(SessionSuggestedFix).where(
|
|
SessionSuggestedFix.id == fix_id,
|
|
SessionSuggestedFix.session_id == session_id,
|
|
)
|
|
)
|
|
fix = result.scalar_one_or_none()
|
|
if fix is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found"
|
|
)
|
|
|
|
if fix.status != "proposed":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Apply is only valid from 'proposed'; fix is already '{fix.status}'",
|
|
)
|
|
|
|
# Idempotent: already stamped → return as-is without bumping state_version again.
|
|
if fix.applied_at is not None:
|
|
return SessionSuggestedFixResponse.model_validate(fix)
|
|
|
|
fix.applied_at = datetime.now(timezone.utc)
|
|
|
|
# Bump state_version so preview generators see the verifying-phase signal.
|
|
await db.execute(
|
|
update(AISession)
|
|
.where(AISession.id == session_id)
|
|
.values(state_version=AISession.state_version + 1)
|
|
)
|
|
|
|
await db.commit()
|
|
await db.refresh(fix)
|
|
return SessionSuggestedFixResponse.model_validate(fix)
|
|
|
|
|
|
# ── Suggested fix: outcome ────────────────────────────────────────────────
|
|
|
|
@router.patch(
|
|
"/suggested-fixes/{fix_id}/outcome",
|
|
response_model=SessionSuggestedFixResponse,
|
|
)
|
|
async def patch_suggested_fix_outcome(
|
|
session_id: UUID,
|
|
fix_id: UUID,
|
|
body: SessionSuggestedFixOutcomeRequest,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
) -> SessionSuggestedFixResponse:
|
|
"""Record the engineer's outcome for an applied fix.
|
|
|
|
See `SessionSuggestedFixOutcomeRequest` for transition rules.
|
|
"""
|
|
await _load_session_or_404(db, session_id)
|
|
now = datetime.now(timezone.utc)
|
|
|
|
result = await db.execute(
|
|
select(SessionSuggestedFix).where(
|
|
SessionSuggestedFix.id == fix_id,
|
|
SessionSuggestedFix.session_id == session_id,
|
|
)
|
|
)
|
|
fix = result.scalar_one_or_none()
|
|
if fix is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found"
|
|
)
|
|
|
|
if body.outcome == "applied_partial" and not (body.notes and body.notes.strip()):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="notes are required when outcome is applied_partial",
|
|
)
|
|
if body.outcome == "applied_pending" and not (body.notes and body.notes.strip()):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="notes are required when outcome is applied_pending",
|
|
)
|
|
|
|
TERMINAL = {"applied_success", "applied_failed", "dismissed"}
|
|
if fix.status in TERMINAL:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Fix is already in terminal status {fix.status!r}",
|
|
)
|
|
|
|
fix.status = body.outcome
|
|
if body.outcome == "applied_partial":
|
|
fix.partial_notes = (body.notes or "").strip() or None
|
|
elif body.outcome == "applied_pending":
|
|
# Pending is parked, not terminal — keep applied_at, do NOT stamp
|
|
# verified_at. Reason explains what the engineer is waiting on.
|
|
fix.pending_reason = (body.notes or "").strip() or None
|
|
elif body.outcome == "applied_failed":
|
|
fix.failure_reason = (body.notes or "").strip() or None
|
|
fix.verified_at = now
|
|
elif body.outcome == "applied_success":
|
|
fix.verified_at = now
|
|
# dismissed: no timestamp/notes stamping
|
|
|
|
if fix.applied_at is None and body.outcome != "dismissed":
|
|
fix.applied_at = now
|
|
|
|
# Clear any pending AI outcome proposal — engineer has taken a terminal action.
|
|
fix.ai_outcome_proposal = None
|
|
|
|
# Outcome changes the bundle that resolution-note/escalation-package
|
|
# previews see, so bump state_version inside the same transaction —
|
|
# mirrors the pattern in record_decision above.
|
|
await db.execute(
|
|
update(AISession)
|
|
.where(AISession.id == session_id)
|
|
.values(state_version=AISession.state_version + 1)
|
|
)
|
|
|
|
await db.commit()
|
|
await db.refresh(fix)
|
|
return SessionSuggestedFixResponse.model_validate(fix)
|
|
|
|
|
|
# ── Suggested fix: attach drafted script ─────────────────────────────────────
|
|
|
|
@router.patch(
|
|
"/suggested-fixes/{fix_id}/script",
|
|
response_model=SessionSuggestedFixResponse,
|
|
)
|
|
async def patch_suggested_fix_script(
|
|
session_id: UUID,
|
|
fix_id: UUID,
|
|
body: SessionSuggestedFixScriptRequest,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
) -> SessionSuggestedFixResponse:
|
|
"""Attach an engineer-drafted script to a suggested fix.
|
|
|
|
Called by the inline Script Builder tab on Submit. Does NOT stamp
|
|
applied_at — a draft is not an application. Bumps state_version so
|
|
the Resolve/Escalate preview bundles regenerate.
|
|
"""
|
|
await _load_session_or_404(db, session_id)
|
|
|
|
fix = await db.scalar(
|
|
select(SessionSuggestedFix).where(
|
|
SessionSuggestedFix.id == fix_id,
|
|
SessionSuggestedFix.session_id == session_id,
|
|
)
|
|
)
|
|
if fix is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found")
|
|
|
|
TERMINAL = {"applied_success", "applied_failed", "dismissed"}
|
|
if fix.status in TERMINAL:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Fix is already in terminal status {fix.status!r}",
|
|
)
|
|
|
|
fix.ai_drafted_script = body.ai_drafted_script
|
|
fix.ai_drafted_parameters = body.ai_drafted_parameters
|
|
|
|
# Bump state_version on the parent session — previews cached by
|
|
# (session_id, state_version) must regenerate to reflect the new draft.
|
|
await db.execute(
|
|
update(AISession)
|
|
.where(AISession.id == session_id)
|
|
.values(state_version=AISession.state_version + 1)
|
|
)
|
|
|
|
await db.commit()
|
|
await db.refresh(fix)
|
|
return SessionSuggestedFixResponse.model_validate(fix)
|
|
|
|
|
|
# ── Suggested fix: clear AI outcome proposal ("Not yet") ─────────────────────
|
|
|
|
@router.delete(
|
|
"/suggested-fixes/{fix_id}/ai-outcome-proposal",
|
|
response_model=SessionSuggestedFixResponse,
|
|
)
|
|
async def clear_ai_outcome_proposal(
|
|
session_id: UUID,
|
|
fix_id: UUID,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
) -> SessionSuggestedFixResponse:
|
|
"""Explicitly dismiss the AI-proposed outcome banner ("Not yet").
|
|
|
|
Clears `ai_outcome_proposal` without touching status or state_version
|
|
(this is pure UI state, not outcome data). Idempotent: returns 200 even
|
|
when the field is already null. After this call the banner will not
|
|
re-surface on the next refreshSessionDerived unless the AI emits a new
|
|
proposal.
|
|
"""
|
|
await _load_session_or_404(db, session_id)
|
|
|
|
result = await db.execute(
|
|
select(SessionSuggestedFix).where(
|
|
SessionSuggestedFix.id == fix_id,
|
|
SessionSuggestedFix.session_id == session_id,
|
|
)
|
|
)
|
|
fix = result.scalar_one_or_none()
|
|
if fix is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found"
|
|
)
|
|
|
|
fix.ai_outcome_proposal = None
|
|
|
|
await db.commit()
|
|
await db.refresh(fix)
|
|
return SessionSuggestedFixResponse.model_validate(fix)
|
|
|
|
|
|
async def _summarize_session_for_extraction(
|
|
db: AsyncSession, session_id: UUID,
|
|
) -> str:
|
|
"""Compact fact list for TemplateExtractionService context.
|
|
|
|
We don't send the full chat transcript — the extractor only needs enough
|
|
signal to decide which values in the script are session-specific (and
|
|
therefore worth parameterizing).
|
|
"""
|
|
result = await db.execute(
|
|
select(SessionFact)
|
|
.where(
|
|
SessionFact.session_id == session_id,
|
|
SessionFact.deleted_at.is_(None),
|
|
)
|
|
.order_by(SessionFact.created_at.asc())
|
|
)
|
|
facts = list(result.scalars().all())
|
|
if not facts:
|
|
return ""
|
|
lines = [f"- {f.text}" for f in facts]
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ── Resolution note preview ────────────────────────────────────────────────
|
|
|
|
@router.post(
|
|
"/resolution-note/preview",
|
|
response_model=ResolutionNotePreviewResponse,
|
|
)
|
|
async def resolution_note_preview(
|
|
session_id: UUID,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
) -> ResolutionNotePreviewResponse:
|
|
"""Generate (or return cached) draft markdown for the Resolve note.
|
|
|
|
Cache key: `(resolution_note, session_id, state_version)`. State_version is
|
|
bumped by every fact / suggested-fix / script-generation write, so two
|
|
consecutive calls with no intervening writes return the same cached
|
|
payload (and won't pay for a Sonnet call).
|
|
|
|
Posted to PSA in Phase 4. Until then, this endpoint is read-only.
|
|
"""
|
|
await _load_session_or_404(db, session_id)
|
|
gen = ResolutionNoteGeneratorService(db)
|
|
try:
|
|
payload = await gen.generate_or_get_cached(session_id)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
|
except Exception as e:
|
|
logger.exception("Resolution note preview failed for session %s", session_id)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"Resolution-note generator error ({type(e).__name__})",
|
|
)
|
|
return ResolutionNotePreviewResponse(**payload)
|
|
|
|
|
|
# ── Phase 4: escalation-package preview ────────────────────────────────────
|
|
|
|
@router.post(
|
|
"/escalation-package/preview",
|
|
response_model=ResolutionNotePreviewResponse,
|
|
)
|
|
async def escalation_package_preview(
|
|
session_id: UUID,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
) -> ResolutionNotePreviewResponse:
|
|
"""Generate (or return cached) draft markdown for the Escalate handoff package.
|
|
|
|
Same caching story as the resolution-note preview: keyed on
|
|
`(session_id, state_version)`. Separate cache kind so a Resolve preview
|
|
and an Escalate preview for the same state can coexist.
|
|
"""
|
|
await _load_session_or_404(db, session_id)
|
|
gen = EscalationPackageGeneratorService(db)
|
|
try:
|
|
payload = await gen.generate_or_get_cached(session_id)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
|
except Exception as e:
|
|
logger.exception("Escalation package preview failed for session %s", session_id)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"Escalation-package generator error ({type(e).__name__})",
|
|
)
|
|
return ResolutionNotePreviewResponse(**payload)
|
|
|
|
|
|
# ── Phase 4: Resolve & post ────────────────────────────────────────────────
|
|
|
|
@router.post(
|
|
"/resolution-note/post",
|
|
response_model=ResolutionPostResponse,
|
|
)
|
|
async def post_resolution_note(
|
|
session_id: UUID,
|
|
body: ResolutionNotePostRequest,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
) -> ResolutionPostResponse:
|
|
"""Commit the engineer-edited resolution note and close the session.
|
|
|
|
Three outcomes:
|
|
- **External post + status verified** — session.status='resolved',
|
|
markdown + external_id + posted_at persisted, CW status flipped to
|
|
the configured Resolved status ID and re-fetch-verified.
|
|
- **External post only** — markdown posted, but no cw_resolved_status_id
|
|
configured → session.status='resolved', `status_transition_skipped_reason`
|
|
explains the skip. Not an error — posting the note is meaningful.
|
|
- **Local-only** — session has no linked PSA ticket → markdown stored on
|
|
`resolution_note_markdown`, session.status='resolved', outcome =
|
|
'resolved_local'. No external call.
|
|
|
|
Status verification failure raises 502: the engineer intended to close
|
|
the ticket but we cannot confirm it actually closed. Surfacing silent
|
|
success would be a footgun.
|
|
"""
|
|
session_obj = await _load_session_or_404(db, session_id)
|
|
if session_obj.status not in ("active", "paused", "requesting_escalation", "escalated"):
|
|
# Already-resolved sessions shouldn't be re-posted; caller should
|
|
# query first. escalated→resolved is allowed (engineer revised course).
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Session is already {session_obj.status}",
|
|
)
|
|
|
|
service = PSAWritebackService(db)
|
|
summary = (body.resolution_summary or body.markdown.strip().splitlines()[0])[:500]
|
|
|
|
# Local-only path — no PSA ticket linked, nothing to post.
|
|
if not session_obj.psa_ticket_id or not session_obj.psa_connection_id:
|
|
session_obj.resolution_note_markdown = body.markdown.strip()
|
|
session_obj.status = "resolved"
|
|
session_obj.resolved_at = datetime.now(timezone.utc)
|
|
session_obj.resolution_summary = summary
|
|
await db.commit()
|
|
return ResolutionPostResponse(
|
|
outcome="resolved_local",
|
|
session_status=session_obj.status,
|
|
)
|
|
|
|
try:
|
|
posted = await service.post_resolution_note(session_obj, body.markdown)
|
|
except Exception as e:
|
|
logger.exception("post_resolution_note failed for session %s", session_id)
|
|
await db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"PSA post failed ({type(e).__name__})",
|
|
)
|
|
|
|
# Attempt the status transition if configured; failed verification is
|
|
# surfaced loudly (status_code 502) per the ConnectWise anti-silent-
|
|
# success principle. Not configured → skip with a reason, not an error.
|
|
target_status_id = await service.resolved_status_id_for_account(session_obj.account_id)
|
|
verified_status_id: int | None = None
|
|
verified_status_name: str | None = None
|
|
skipped_reason: str | None = None
|
|
if target_status_id is None:
|
|
skipped_reason = (
|
|
"No cw_resolved_status_id configured in account_settings.preferences — "
|
|
"note posted, status unchanged."
|
|
)
|
|
else:
|
|
try:
|
|
result = await service.transition_ticket_status(session_obj, target_status_id)
|
|
verified_status_id = result["verified_status_id"]
|
|
verified_status_name = result["verified_status_name"]
|
|
except PSAStatusVerificationError as e:
|
|
logger.error("Status verification failed for session %s: %s", session_id, e)
|
|
# Note was already posted — roll that partial side effect back in
|
|
# the session record (the CW note itself can't be un-posted).
|
|
await db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=str(e),
|
|
)
|
|
except Exception as e:
|
|
logger.exception("Status transition failed for session %s", session_id)
|
|
await db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"PSA status transition error ({type(e).__name__})",
|
|
)
|
|
|
|
session_obj.status = "resolved"
|
|
session_obj.resolved_at = datetime.now(timezone.utc)
|
|
session_obj.resolution_summary = summary
|
|
await db.commit()
|
|
|
|
return ResolutionPostResponse(
|
|
outcome="resolved",
|
|
session_status=session_obj.status,
|
|
external_id=posted["external_id"],
|
|
posted_at=posted["posted_at"],
|
|
verified_status_id=verified_status_id,
|
|
verified_status_name=verified_status_name,
|
|
status_transition_skipped_reason=skipped_reason,
|
|
)
|
|
|
|
|
|
# ── Phase 4: Escalate & post ──────────────────────────────────────────────
|
|
|
|
@router.post(
|
|
"/escalation-package/post",
|
|
response_model=ResolutionPostResponse,
|
|
)
|
|
async def post_escalation_package(
|
|
session_id: UUID,
|
|
body: EscalationPackagePostRequest,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
) -> ResolutionPostResponse:
|
|
"""Commit the engineer-edited escalation package and mark the session escalated.
|
|
|
|
Structure mirrors post_resolution_note:
|
|
- Local-only when no PSA ticket: markdown stored, session.status='escalated'.
|
|
- PSA post: internal-analysis note (handoff is for the next engineer,
|
|
not the customer), optional status transition via cw_escalated_status_id,
|
|
re-fetch verified.
|
|
"""
|
|
session_obj = await _load_session_or_404(db, session_id)
|
|
if session_obj.status not in ("active", "paused", "resolved"):
|
|
# resolved→escalated is allowed (engineer realized they need help
|
|
# after closing); escalated→escalated would be a no-op, block it.
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Session is already {session_obj.status}",
|
|
)
|
|
|
|
service = PSAWritebackService(db)
|
|
reason = body.escalation_reason or body.markdown.strip().splitlines()[0][:500]
|
|
|
|
if not session_obj.psa_ticket_id or not session_obj.psa_connection_id:
|
|
session_obj.escalation_package_markdown = body.markdown.strip()
|
|
session_obj.status = "escalated"
|
|
session_obj.escalation_reason = reason
|
|
await db.commit()
|
|
return ResolutionPostResponse(
|
|
outcome="escalated_local",
|
|
session_status=session_obj.status,
|
|
)
|
|
|
|
try:
|
|
posted = await service.post_escalation_package(session_obj, body.markdown)
|
|
except Exception as e:
|
|
logger.exception("post_escalation_package failed for session %s", session_id)
|
|
await db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"PSA post failed ({type(e).__name__})",
|
|
)
|
|
|
|
target_status_id = await service.escalated_status_id_for_account(session_obj.account_id)
|
|
verified_status_id: int | None = None
|
|
verified_status_name: str | None = None
|
|
skipped_reason: str | None = None
|
|
if target_status_id is None:
|
|
skipped_reason = (
|
|
"No cw_escalated_status_id configured — package posted, status unchanged."
|
|
)
|
|
else:
|
|
try:
|
|
result = await service.transition_ticket_status(session_obj, target_status_id)
|
|
verified_status_id = result["verified_status_id"]
|
|
verified_status_name = result["verified_status_name"]
|
|
except PSAStatusVerificationError as e:
|
|
logger.error("Status verification failed for session %s: %s", session_id, e)
|
|
await db.rollback()
|
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
|
|
except Exception as e:
|
|
logger.exception("Status transition failed for session %s", session_id)
|
|
await db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"PSA status transition error ({type(e).__name__})",
|
|
)
|
|
|
|
session_obj.status = "escalated"
|
|
session_obj.escalation_reason = reason
|
|
await db.commit()
|
|
|
|
return ResolutionPostResponse(
|
|
outcome="escalated",
|
|
session_status=session_obj.status,
|
|
external_id=posted["external_id"],
|
|
posted_at=posted["posted_at"],
|
|
verified_status_id=verified_status_id,
|
|
verified_status_name=verified_status_name,
|
|
status_transition_skipped_reason=skipped_reason,
|
|
)
|
|
|
|
|
|
# ── Helper used by tests ───────────────────────────────────────────────────
|
|
|
|
def _clear_preview_cache_for_tests() -> None:
|
|
"""Reset the singleton cache between tests."""
|
|
preview_cache._store.clear() # noqa: SLF001 — test-only access
|