Files
resolutionflow/backend/app/services/psa_writeback_service.py
Michael Chihlas 8fd2c1bac6
All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
feat(pilot): Phase 4 — Resolve + Escalate PSA writebacks with status verification
Wires the preview popover's Confirm & post action to ConnectWise (and,
via the provider pattern, any future PSA). Adds the parallel Escalate
flow with the handoff-oriented five-section markdown. Sessions without a
linked PSA ticket resolve/escalate locally — markdown stored, status
flipped, nothing posted externally.

Backend:
- EscalationPackageGeneratorService: Sonnet, five sections (Problem /
  What we've confirmed / What we've tried / Current hypothesis /
  Suggested next steps). Shares the preview_cache with a separate KIND
  so Resolve and Escalate previews for the same state coexist.
- PSAWritebackService: post_resolution_note (RESOLUTION note type,
  customer-visible), post_escalation_package (INTERNAL_ANALYSIS,
  handoff for the next engineer only), transition_ticket_status with
  mandatory re-fetch verification. PSAStatusVerificationError surfaces
  loudly when CW silently rejects a status change — the
  ConnectWise anti-pattern CLAUDE.md flags.
- Endpoints:
  * POST /ai-sessions/{id}/escalation-package/preview
  * POST /ai-sessions/{id}/resolution-note/post
  * POST /ai-sessions/{id}/escalation-package/post
  Outcomes: "resolved" / "escalated" with external_id + verified status,
  "resolved_local" / "escalated_local" when no PSA linked.
- Target CW status IDs live in account_settings.preferences
  (cw_resolved_status_id, cw_escalated_status_id). When unset, the post
  proceeds without a status transition — response includes a
  status_transition_skipped_reason rather than silently erroring.
- 7 tests: local-only path, PSA happy path with verified transition,
  status verification failure → 502, skipped transition when
  unconfigured, 409 on already-resolved re-post, escalate parallel path,
  internal-analysis note type enforced.

Frontend:
- ResolutionNotePreview now kind-parameterized ('resolve' | 'escalate')
  with inline edit + Confirm & post. Preview loads from the matching
  backend endpoint; posting calls the matching endpoint; outcome toast
  surfaces the verified CW status or the local-only result.
- AssistantChatPage: previewKind state replaces previewOpen; two toggle
  buttons (Preview Resolve note / Escalate instead) in the lane's bottom
  slot. handleConfirmPost dispatches by kind.

Verified 2026-04-22:
- Local-only Resolve + Escalate round-trip against the dev stack.
- Live Sonnet escalation-package preview; cache hit on repeat call
  with no state change (separate cache kind from resolution-note).
- PSA post + status-verification paths covered by mocked-provider pytest
  cases. Live CW round-trip pending a test CW instance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:54:54 -04:00

224 lines
8.8 KiB
Python

"""PSA writeback for FlowPilot Phase 4 — Resolve + Escalate round-trip.
Three primitives:
- `post_resolution_note` — post the engineer-edited resolution markdown to
the PSA ticket, store `{external_id, posted_at}` on the session.
- `post_escalation_package` — same pattern for the Escalate flow.
- `transition_ticket_status` — patch the ticket status, then re-fetch and
verify the change actually took. Failed verification raises loudly so the
UI never reports silent success (per the existing ConnectWise integration
principle called out in FLOWPILOT-MIGRATION.md Section 6.5 and CLAUDE.md).
The target status IDs live in `account_settings.preferences`
(`cw_resolved_status_id`, `cw_escalated_status_id`). When unset, the status
transition is a no-op and the endpoint response says so — we do not guess a
default because CW status IDs are board-specific.
Local-only path: callers handle sessions without `psa_ticket_id` before
calling this service. Nothing here tries to "post locally" — the service's
job ends at the PSA boundary.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.account_settings import AccountSettings
from app.models.ai_session import AISession
from app.services.psa.exceptions import PSAConnectionError
from app.services.psa.registry import get_provider_for_connection
from app.services.psa.types import NoteType
logger = logging.getLogger(__name__)
class PSAStatusVerificationError(RuntimeError):
"""Raised when a ticket status transition didn't stick on re-fetch.
The `update_ticket_status` call returned OK but the subsequent
`get_ticket` still shows the prior status (or some unrelated one).
This is the exact failure mode CLAUDE.md flags as a ConnectWise
anti-pattern: reporting success when nothing changed.
"""
def __init__(self, ticket_id: str, expected_status_id: int, observed_status: Any) -> None:
super().__init__(
f"Ticket {ticket_id} status transition to {expected_status_id} "
f"did not verify — observed {observed_status!r} after re-fetch."
)
self.ticket_id = ticket_id
self.expected_status_id = expected_status_id
self.observed_status = observed_status
class PSAWritebackService:
"""Thin orchestration over the PSA provider for FlowPilot writebacks.
Instances are per-request — the AsyncSession is the one handling the
current HTTP call, and the provider is resolved lazily from the session's
`psa_connection_id`.
"""
def __init__(self, db: AsyncSession) -> None:
self.db = db
# ── Public API ────────────────────────────────────────────────────────
async def post_resolution_note(
self, session: AISession, markdown: str
) -> dict[str, Any]:
"""Post `markdown` as a resolution note on the linked CW ticket.
On success, persists `resolution_note_markdown`, `_posted_at`,
`_external_id` on the session and returns the same triple. Caller is
responsible for committing the transaction.
"""
return await self._post_note(
session=session,
markdown=markdown,
note_type=NoteType.RESOLUTION,
markdown_col="resolution_note_markdown",
posted_at_col="resolution_note_posted_at",
external_id_col="resolution_note_external_id",
kind="resolution",
)
async def post_escalation_package(
self, session: AISession, markdown: str
) -> dict[str, Any]:
"""Post `markdown` as an escalation handoff note on the CW ticket."""
return await self._post_note(
session=session,
markdown=markdown,
# Internal-analysis visibility: the handoff is for the next engineer,
# not the customer. CW fires no notifications, keeps the note internal.
note_type=NoteType.INTERNAL_ANALYSIS,
markdown_col="escalation_package_markdown",
posted_at_col="escalation_package_posted_at",
external_id_col="escalation_package_external_id",
kind="escalation",
)
async def transition_ticket_status(
self,
session: AISession,
target_status_id: int,
) -> dict[str, Any]:
"""PATCH ticket status, then re-fetch and verify.
Returns `{"success": True, "verified_status_id": <int>, "verified_status_name": <str>}`
when the observed status matches. Raises `PSAStatusVerificationError`
when the transition didn't take (most common real-world failure: CW
requires certain fields before allowing a status change to
Resolved — the PATCH returns 200 but the status silently stays put).
"""
if not session.psa_ticket_id or not session.psa_connection_id:
raise ValueError("Session has no linked PSA ticket for status transition")
provider = await get_provider_for_connection(session.psa_connection_id, self.db)
await provider.update_ticket_status(
ticket_id=session.psa_ticket_id, status_id=target_status_id,
)
# Verify by re-fetch — this is the load-bearing step.
verification = await provider.get_ticket(session.psa_ticket_id)
observed_id = getattr(verification, "status_id", None)
observed_name = getattr(verification, "status_name", None)
if observed_id != target_status_id:
raise PSAStatusVerificationError(
ticket_id=session.psa_ticket_id,
expected_status_id=target_status_id,
observed_status={"id": observed_id, "name": observed_name},
)
return {
"success": True,
"verified_status_id": observed_id,
"verified_status_name": observed_name,
}
async def resolved_status_id_for_account(
self, account_id: UUID
) -> int | None:
"""Return the configured CW "Resolved" status ID for the account.
None means "no transition configured" — callers should skip the
transition (posting the note is still meaningful). This lives in
account_settings.preferences per the Phase 1 JSONB grab-bag design.
"""
raw = await AccountSettings.get_setting(self.db, account_id, "cw_resolved_status_id", None)
return self._coerce_status_id(raw)
async def escalated_status_id_for_account(
self, account_id: UUID
) -> int | None:
raw = await AccountSettings.get_setting(self.db, account_id, "cw_escalated_status_id", None)
return self._coerce_status_id(raw)
# ── Internals ─────────────────────────────────────────────────────────
async def _post_note(
self,
*,
session: AISession,
markdown: str,
note_type: str,
markdown_col: str,
posted_at_col: str,
external_id_col: str,
kind: str,
) -> dict[str, Any]:
if not session.psa_ticket_id or not session.psa_connection_id:
raise ValueError(f"Session has no linked PSA ticket for {kind} post")
markdown = (markdown or "").strip()
if not markdown:
raise ValueError(f"{kind} markdown is empty")
try:
provider = await get_provider_for_connection(session.psa_connection_id, self.db)
except PSAConnectionError:
# Connection could have been deleted or deactivated since session
# creation — propagate as a clear error for the endpoint to surface.
logger.exception(
"PSA connection %s is no longer available for session %s",
session.psa_connection_id, session.id,
)
raise
posted = await provider.post_note(
ticket_id=session.psa_ticket_id,
text=markdown,
note_type=note_type,
)
posted_at = datetime.now(timezone.utc)
setattr(session, markdown_col, markdown)
setattr(session, posted_at_col, posted_at)
setattr(session, external_id_col, str(posted.id) if posted.id else None)
return {
"external_id": str(posted.id) if posted.id else None,
"posted_at": posted_at,
"kind": kind,
}
@staticmethod
def _coerce_status_id(raw: Any) -> int | None:
if raw is None:
return None
try:
return int(raw)
except (TypeError, ValueError):
logger.warning(
"Non-integer CW status ID in account_settings.preferences: %r",
raw,
)
return None