Files
resolutionflow/backend/app/api/endpoints/session_suggested_fixes.py
Michael Chihlas 00663a4734
Some checks failed
Mirror to GitHub / mirror (push) Has been cancelled
CI / backend (pull_request) Successful in 10m43s
CI / frontend (pull_request) Successful in 5m42s
CI / e2e (pull_request) Successful in 11m13s
feat(suggested-fix): add applied_pending status for deferred verification
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>
2026-04-30 17:32:37 -04:00

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