feat(pilot): Phase 4 — Resolve + Escalate PSA writebacks with status verification
All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
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>
This commit is contained in:
@@ -1,8 +1,14 @@
|
|||||||
"""Suggested-fix and resolution-note preview endpoints (Phase 3).
|
"""Suggested-fix + resolution-note / escalation-package preview-and-post endpoints.
|
||||||
|
|
||||||
Per FLOWPILOT-MIGRATION.md Sections 5.2 + 5.4. The preview is keyed on
|
Phase 3: active suggested fix lookup + decision recording, resolution-note
|
||||||
`(session_id, ai_sessions.state_version)` so repeat fetches against the same
|
preview with state_version cache.
|
||||||
state hit the in-process cache instead of paying for a Sonnet call.
|
|
||||||
|
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
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -18,12 +24,20 @@ from app.models.ai_session import AISession
|
|||||||
from app.models.session_suggested_fix import SessionSuggestedFix
|
from app.models.session_suggested_fix import SessionSuggestedFix
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.session_suggested_fix import (
|
from app.schemas.session_suggested_fix import (
|
||||||
|
EscalationPackagePostRequest,
|
||||||
|
ResolutionNotePostRequest,
|
||||||
ResolutionNotePreviewResponse,
|
ResolutionNotePreviewResponse,
|
||||||
|
ResolutionPostResponse,
|
||||||
SessionSuggestedFixDecisionRequest,
|
SessionSuggestedFixDecisionRequest,
|
||||||
SessionSuggestedFixDecisionResponse,
|
SessionSuggestedFixDecisionResponse,
|
||||||
SessionSuggestedFixResponse,
|
SessionSuggestedFixResponse,
|
||||||
)
|
)
|
||||||
|
from app.services.escalation_package_generator import EscalationPackageGeneratorService
|
||||||
from app.services.preview_cache import preview_cache
|
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.resolution_note_generator import ResolutionNoteGeneratorService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -176,6 +190,246 @@ async def resolution_note_preview(
|
|||||||
return ResolutionNotePreviewResponse(**payload)
|
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 ───────────────────────────────────────────────────
|
# ── Helper used by tests ───────────────────────────────────────────────────
|
||||||
|
|
||||||
def _clear_preview_cache_for_tests() -> None:
|
def _clear_preview_cache_for_tests() -> None:
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ class Settings(BaseSettings):
|
|||||||
# matters more than latency; the in-process state_version cache keeps
|
# matters more than latency; the in-process state_version cache keeps
|
||||||
# cost manageable.
|
# cost manageable.
|
||||||
"resolution_note": "standard",
|
"resolution_note": "standard",
|
||||||
|
# FlowPilot migration Phase 4 — escalation handoff package. Parallel
|
||||||
|
# to resolution_note: Sonnet, same cache story, no MCP.
|
||||||
|
"escalation_package": "standard",
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_model_for_action(self, action_type: str) -> str:
|
def get_model_for_action(self, action_type: str) -> str:
|
||||||
|
|||||||
@@ -61,3 +61,35 @@ class ResolutionNotePreviewResponse(BaseModel):
|
|||||||
target_ticket_ref: str | None
|
target_ticket_ref: str | None
|
||||||
state_version: int
|
state_version: int
|
||||||
from_cache: bool
|
from_cache: bool
|
||||||
|
|
||||||
|
|
||||||
|
# ── Phase 4: Resolve + Escalate post ───────────────────────────────────────
|
||||||
|
|
||||||
|
class ResolutionNotePostRequest(BaseModel):
|
||||||
|
"""Engineer-edited resolution markdown. Server posts to PSA + marks resolved."""
|
||||||
|
markdown: str = Field(..., min_length=1, max_length=20_000)
|
||||||
|
# Optional override for resolution summary shown on the session listing;
|
||||||
|
# defaults to the first line of the markdown if omitted.
|
||||||
|
resolution_summary: str | None = Field(None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class EscalationPackagePostRequest(BaseModel):
|
||||||
|
markdown: str = Field(..., min_length=1, max_length=20_000)
|
||||||
|
# Free-text reason shown in session listings and escalation queue.
|
||||||
|
escalation_reason: str | None = Field(None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class ResolutionPostResponse(BaseModel):
|
||||||
|
"""Response shape for both Resolve/Escalate POST endpoints."""
|
||||||
|
# "resolved" / "escalated" / "resolved_local" / "escalated_local"
|
||||||
|
# The _local variants indicate the session has no linked PSA ticket —
|
||||||
|
# markdown is stored, session state is updated, nothing was posted externally.
|
||||||
|
outcome: str
|
||||||
|
session_status: str
|
||||||
|
external_id: str | None = None
|
||||||
|
posted_at: datetime | None = None
|
||||||
|
# Populated when a status transition was attempted and verified. None
|
||||||
|
# when no target status is configured in account_settings.preferences.
|
||||||
|
verified_status_id: int | None = None
|
||||||
|
verified_status_name: str | None = None
|
||||||
|
status_transition_skipped_reason: str | None = None
|
||||||
|
|||||||
277
backend/app/services/escalation_package_generator.py
Normal file
277
backend/app/services/escalation_package_generator.py
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"""EscalationPackageGeneratorService — drafts the handoff package for a session.
|
||||||
|
|
||||||
|
Parallel to ResolutionNoteGeneratorService but oriented around handoff to
|
||||||
|
another engineer instead of closing the ticket. The output markdown follows
|
||||||
|
FLOWPILOT-MIGRATION.md Section 6.3:
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
## What we've confirmed
|
||||||
|
## What we've tried
|
||||||
|
## Current hypothesis
|
||||||
|
## Suggested next steps
|
||||||
|
|
||||||
|
Same caching story as resolution-note previews: keyed on
|
||||||
|
`(session_id, ai_sessions.state_version)` via `preview_cache`, invalidated by
|
||||||
|
any fact / suggested-fix / script-generation write.
|
||||||
|
|
||||||
|
Model: Sonnet (`escalation_package` action tier per Section 6.6). MCP off.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.ai_provider import get_ai_provider
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.ai_session import AISession
|
||||||
|
from app.models.script_template import ScriptGeneration, ScriptTemplate
|
||||||
|
from app.models.session_fact import SessionFact
|
||||||
|
from app.models.session_suggested_fix import SessionSuggestedFix
|
||||||
|
from app.services.preview_cache import preview_cache
|
||||||
|
from app.services.script_template_engine import ScriptTemplateEngine
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_ESCALATION_SYSTEM_PROMPT = """\
|
||||||
|
You produce structured escalation handoff packages for an MSP troubleshooting \
|
||||||
|
platform. The package is read by the next engineer picking up the ticket; it \
|
||||||
|
must give them a running start without making them re-read the chat transcript.
|
||||||
|
|
||||||
|
Output exactly this markdown structure, no preamble, no closing remarks, no \
|
||||||
|
extra headings:
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
<one short paragraph stating the issue the first engineer was working on, \
|
||||||
|
past tense, no hedging. Derived from the session's intake/title and incident \
|
||||||
|
header.>
|
||||||
|
|
||||||
|
## What we've confirmed
|
||||||
|
<bulleted list of facts from the "What we know" section, each a short line. \
|
||||||
|
If there are no facts, write "Nothing confirmed yet." and continue.>
|
||||||
|
|
||||||
|
## What we've tried
|
||||||
|
<bulleted list of diagnostic checks run (from the [diagnostic_check] facts) \
|
||||||
|
and scripts generated during the session. State what each revealed or did, \
|
||||||
|
not what was attempted without an outcome. If nothing has been tried, write \
|
||||||
|
"No diagnostic actions run yet." and continue.>
|
||||||
|
|
||||||
|
## Current hypothesis
|
||||||
|
<one short paragraph naming the active suggested fix and its confidence. If \
|
||||||
|
confidence is below 60% or there is no active fix, say so plainly: "No leading \
|
||||||
|
hypothesis yet — symptoms are still being narrowed.">
|
||||||
|
|
||||||
|
## Suggested next steps
|
||||||
|
<bulleted list of 2-4 concrete next actions the receiving engineer should \
|
||||||
|
take. Prefer specifics: commands to run, tickets to check, people to contact. \
|
||||||
|
Derive from the gap between confirmed facts and a complete resolution. If the \
|
||||||
|
active suggested fix is high confidence (>80%), the first bullet is "Try the \
|
||||||
|
suggested fix: <title>.">
|
||||||
|
|
||||||
|
Strict rules:
|
||||||
|
- Use ONLY the input I provide. Never invent command names, KB articles, or \
|
||||||
|
configuration specifics that aren't in the input.
|
||||||
|
- Do not include placeholder text like "TBD" or empty bullets.
|
||||||
|
- Do not include the engineer's name, the AI's name, session IDs, or the \
|
||||||
|
chat transcript verbatim.
|
||||||
|
- Markdown headings exactly as shown (## level), no bolding.
|
||||||
|
- The tone is a peer handing off to a peer, not a status report.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class EscalationPackageGeneratorService:
|
||||||
|
"""Generates and caches the five-section Escalate handoff markdown."""
|
||||||
|
|
||||||
|
KIND = "escalation_package"
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def generate_or_get_cached(
|
||||||
|
self, session_id: UUID, *, force: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
session = await self._load_session(session_id)
|
||||||
|
cached = preview_cache.get(self.KIND, session.id, session.state_version) if not force else None
|
||||||
|
if cached is not None:
|
||||||
|
return {**cached, "from_cache": True}
|
||||||
|
|
||||||
|
markdown = await self._render(session)
|
||||||
|
target = self._target_ticket_ref(session)
|
||||||
|
payload = {
|
||||||
|
"markdown": markdown,
|
||||||
|
"target_ticket_ref": target,
|
||||||
|
"state_version": session.state_version,
|
||||||
|
}
|
||||||
|
preview_cache.set(self.KIND, session.id, session.state_version, payload)
|
||||||
|
return {**payload, "from_cache": False}
|
||||||
|
|
||||||
|
# ── Internals (parallel to ResolutionNoteGenerator) ───────────────────
|
||||||
|
|
||||||
|
async def _load_session(self, session_id: UUID) -> AISession:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(AISession).where(AISession.id == session_id)
|
||||||
|
)
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
if session is None:
|
||||||
|
raise ValueError(f"Session {session_id} not found")
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def _render(self, session: AISession) -> str:
|
||||||
|
facts = await self._load_facts(session.id)
|
||||||
|
active_fix = await self._load_active_fix(session.id)
|
||||||
|
gens = await self._load_redacted_generations(session.id)
|
||||||
|
|
||||||
|
bundle = self._build_input_bundle(session, facts, active_fix, gens)
|
||||||
|
|
||||||
|
model = settings.get_model_for_action("escalation_package")
|
||||||
|
provider = get_ai_provider(model=model)
|
||||||
|
|
||||||
|
system_blocks: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": _ESCALATION_SYSTEM_PROMPT,
|
||||||
|
"cache_control": {"type": "ephemeral"},
|
||||||
|
# cacheable: identical across every escalation-package preview call
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
text, _in, _out = await provider.generate_text(
|
||||||
|
system_prompt=system_blocks,
|
||||||
|
messages=[{"role": "user", "content": bundle}],
|
||||||
|
max_tokens=1400,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Escalation package generation failed for session %s", session.id)
|
||||||
|
raise
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
async def _load_facts(self, session_id: UUID) -> list[SessionFact]:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(SessionFact)
|
||||||
|
.where(
|
||||||
|
SessionFact.session_id == session_id,
|
||||||
|
SessionFact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(SessionFact.created_at.asc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def _load_active_fix(self, session_id: UUID) -> SessionSuggestedFix | None:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(SessionSuggestedFix)
|
||||||
|
.where(
|
||||||
|
SessionSuggestedFix.session_id == session_id,
|
||||||
|
SessionSuggestedFix.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(SessionSuggestedFix.created_at.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
async def _load_redacted_generations(
|
||||||
|
self, session_id: UUID
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(ScriptGeneration)
|
||||||
|
.where(ScriptGeneration.ai_session_id == session_id)
|
||||||
|
.order_by(ScriptGeneration.created_at.asc())
|
||||||
|
)
|
||||||
|
gens = list(result.scalars().all())
|
||||||
|
if not gens:
|
||||||
|
return []
|
||||||
|
|
||||||
|
template_ids = {g.template_id for g in gens}
|
||||||
|
tpl_result = await self.db.execute(
|
||||||
|
select(ScriptTemplate).where(ScriptTemplate.id.in_(template_ids))
|
||||||
|
)
|
||||||
|
templates_by_id = {t.id: t for t in tpl_result.scalars().all()}
|
||||||
|
|
||||||
|
engine = ScriptTemplateEngine()
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for g in gens:
|
||||||
|
tpl = templates_by_id.get(g.template_id)
|
||||||
|
sensitive_keys: set[str] = set()
|
||||||
|
schema = (tpl.parameters_schema if tpl else {}) or {}
|
||||||
|
params = schema.get("parameters") if isinstance(schema, dict) else None
|
||||||
|
if isinstance(params, list):
|
||||||
|
for p in params:
|
||||||
|
if isinstance(p, dict) and p.get("field_type") == "password":
|
||||||
|
k = p.get("key") or p.get("variable_name")
|
||||||
|
if isinstance(k, str):
|
||||||
|
sensitive_keys.add(k)
|
||||||
|
redacted_params = engine.redact_sensitive(g.parameters_used or {}, sensitive_keys)
|
||||||
|
out.append({
|
||||||
|
"template_name": tpl.name if tpl else "(unknown template)",
|
||||||
|
"template_slug": tpl.slug if tpl else None,
|
||||||
|
"parameters_used": redacted_params,
|
||||||
|
"created_at": g.created_at.isoformat(),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _target_ticket_ref(session: AISession) -> str | None:
|
||||||
|
if not session.psa_ticket_id:
|
||||||
|
return None
|
||||||
|
return f"CW #{session.psa_ticket_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_input_bundle(
|
||||||
|
session: AISession,
|
||||||
|
facts: list[SessionFact],
|
||||||
|
active_fix: SessionSuggestedFix | None,
|
||||||
|
generations: list[dict[str, Any]],
|
||||||
|
) -> str:
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append("# Session context")
|
||||||
|
lines.append(f"Title: {session.title or '(untitled)'}")
|
||||||
|
if session.problem_summary:
|
||||||
|
lines.append(f"Problem summary: {session.problem_summary}")
|
||||||
|
if session.problem_domain:
|
||||||
|
lines.append(f"Domain: {session.problem_domain}")
|
||||||
|
intake_text = (session.intake_content or {}).get("text") if isinstance(session.intake_content, dict) else None
|
||||||
|
if intake_text:
|
||||||
|
lines.append(f"Intake message: {intake_text}")
|
||||||
|
if session.psa_ticket_id:
|
||||||
|
lines.append(f"Linked PSA ticket: CW #{session.psa_ticket_id}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("# Confirmed facts (What we know)")
|
||||||
|
if not facts:
|
||||||
|
lines.append("(none)")
|
||||||
|
else:
|
||||||
|
for f in facts:
|
||||||
|
tag = f.source_type
|
||||||
|
summary = f" — {f.source_summary}" if f.source_summary else ""
|
||||||
|
lines.append(f"- [{tag}] {f.text}{summary}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("# Diagnostic checks run during the session")
|
||||||
|
check_facts = [f for f in facts if f.source_type == "diagnostic_check"]
|
||||||
|
if not check_facts and not generations:
|
||||||
|
lines.append("(none)")
|
||||||
|
else:
|
||||||
|
for f in check_facts:
|
||||||
|
lines.append(f"- {f.text}")
|
||||||
|
for g in generations:
|
||||||
|
lines.append(f"- Ran script {g['template_name']} (slug={g['template_slug']})")
|
||||||
|
if g["parameters_used"]:
|
||||||
|
lines.append(f" parameters: {g['parameters_used']}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("# Active suggested fix (current hypothesis)")
|
||||||
|
if active_fix is None:
|
||||||
|
lines.append("(no active suggested fix)")
|
||||||
|
else:
|
||||||
|
lines.append(f"Title: {active_fix.title}")
|
||||||
|
lines.append(f"Confidence: {active_fix.confidence_pct}%")
|
||||||
|
lines.append(f"Description: {active_fix.description}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"Produce the five-section escalation handoff now. Use only the input above."
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
223
backend/app/services/psa_writeback_service.py
Normal file
223
backend/app/services/psa_writeback_service.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"""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
|
||||||
281
backend/tests/test_psa_writeback_phase4.py
Normal file
281
backend/tests/test_psa_writeback_phase4.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
"""API tests for the FlowPilot Phase 4 Resolve + Escalate writeback flow.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Local-only path when no PSA ticket is linked (markdown stored, status flipped,
|
||||||
|
no provider call).
|
||||||
|
- PSA post happy path (provider mocked).
|
||||||
|
- Status transition verified by re-fetch (happy path).
|
||||||
|
- Status verification failure surfaces 502 with a clear error body.
|
||||||
|
- 409 when trying to resolve an already-resolved session / escalate an
|
||||||
|
already-escalated one.
|
||||||
|
- Escalation parallel to resolution (same structure).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.api.endpoints.session_suggested_fixes import _clear_preview_cache_for_tests
|
||||||
|
from app.models.account_settings import AccountSettings
|
||||||
|
from app.models.ai_session import AISession
|
||||||
|
from app.models.psa_connection import PsaConnection
|
||||||
|
from app.services.psa.types import NoteType, PSANote, PSATicket
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_preview_cache():
|
||||||
|
_clear_preview_cache_for_tests()
|
||||||
|
yield
|
||||||
|
_clear_preview_cache_for_tests()
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_session(test_db, user, *, with_psa: bool = False) -> AISession:
|
||||||
|
session_kwargs: dict = dict(
|
||||||
|
user_id=user["user_data"]["id"],
|
||||||
|
account_id=user["user_data"]["account_id"],
|
||||||
|
session_type="chat",
|
||||||
|
intake_type="free_text",
|
||||||
|
intake_content={"text": "phase 4 test"},
|
||||||
|
status="active",
|
||||||
|
confidence_tier="discovery",
|
||||||
|
conversation_messages=[],
|
||||||
|
)
|
||||||
|
if with_psa:
|
||||||
|
# Fake connection — provider factory is patched in each test so we
|
||||||
|
# never touch a real CW instance.
|
||||||
|
from app.services.psa.encryption import encrypt_credentials
|
||||||
|
conn = PsaConnection(
|
||||||
|
account_id=user["user_data"]["account_id"],
|
||||||
|
provider="connectwise",
|
||||||
|
site_url="https://fake.cw.local",
|
||||||
|
company_id="TEST",
|
||||||
|
credentials_encrypted=encrypt_credentials({"public_key": "x", "private_key": "y"}),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
test_db.add(conn)
|
||||||
|
await test_db.flush()
|
||||||
|
session_kwargs["psa_connection_id"] = conn.id
|
||||||
|
session_kwargs["psa_ticket_id"] = "48291"
|
||||||
|
|
||||||
|
session = AISession(**session_kwargs)
|
||||||
|
test_db.add(session)
|
||||||
|
await test_db.commit()
|
||||||
|
await test_db.refresh(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resolve: local-only ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_local_only_when_no_psa_ticket(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session = await _make_session(test_db, test_user, with_psa=False)
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/resolution-note/post",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"markdown": "## Problem\nx\n\n## Resolution\nfixed"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["outcome"] == "resolved_local"
|
||||||
|
assert body["session_status"] == "resolved"
|
||||||
|
assert body["external_id"] is None
|
||||||
|
|
||||||
|
await test_db.refresh(session)
|
||||||
|
assert session.status == "resolved"
|
||||||
|
assert session.resolution_note_markdown == "## Problem\nx\n\n## Resolution\nfixed"
|
||||||
|
assert session.resolved_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resolve: happy path (PSA post + status transition verified) ────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_posts_to_psa_and_verifies_status(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session = await _make_session(test_db, test_user, with_psa=True)
|
||||||
|
|
||||||
|
# Configure the Resolved status ID so the transition is attempted.
|
||||||
|
await AccountSettings.set_setting(
|
||||||
|
test_db, session.account_id, "cw_resolved_status_id", 42,
|
||||||
|
)
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
# Mock provider: post_note returns a fake note, update_ticket_status
|
||||||
|
# returns anything, get_ticket returns the new status_id (matches 42
|
||||||
|
# → verification passes).
|
||||||
|
fake_provider = AsyncMock()
|
||||||
|
fake_provider.post_note = AsyncMock(return_value=PSANote(
|
||||||
|
id="cw-note-777", text="...", note_type=NoteType.RESOLUTION, created_at=None,
|
||||||
|
))
|
||||||
|
fake_provider.update_ticket_status = AsyncMock(return_value=None)
|
||||||
|
fake_provider.get_ticket = AsyncMock(return_value=PSATicket(
|
||||||
|
id="48291", summary="t", status_id=42, status_name="Resolved",
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.psa_writeback_service.get_provider_for_connection",
|
||||||
|
return_value=fake_provider,
|
||||||
|
):
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/resolution-note/post",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"markdown": "## Problem\nx\n\n## Resolution\nfixed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["outcome"] == "resolved"
|
||||||
|
assert body["external_id"] == "cw-note-777"
|
||||||
|
assert body["verified_status_id"] == 42
|
||||||
|
assert body["verified_status_name"] == "Resolved"
|
||||||
|
|
||||||
|
# post_note must have used the RESOLUTION note type
|
||||||
|
fake_provider.post_note.assert_awaited_once()
|
||||||
|
called_note_type = fake_provider.post_note.await_args.kwargs["note_type"]
|
||||||
|
assert called_note_type == NoteType.RESOLUTION
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resolve: status verification failure → 502 ──────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_surfaces_status_verification_failure(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""CW silently rejecting a status change must NOT report silent success."""
|
||||||
|
session = await _make_session(test_db, test_user, with_psa=True)
|
||||||
|
await AccountSettings.set_setting(
|
||||||
|
test_db, session.account_id, "cw_resolved_status_id", 42,
|
||||||
|
)
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
fake_provider = AsyncMock()
|
||||||
|
fake_provider.post_note = AsyncMock(return_value=PSANote(
|
||||||
|
id="cw-note-alpha", text="...", note_type=NoteType.RESOLUTION, created_at=None,
|
||||||
|
))
|
||||||
|
fake_provider.update_ticket_status = AsyncMock(return_value=None)
|
||||||
|
# get_ticket returns a DIFFERENT status_id — the transition didn't stick.
|
||||||
|
fake_provider.get_ticket = AsyncMock(return_value=PSATicket(
|
||||||
|
id="48291", summary="t", status_id=99, status_name="In Progress",
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.psa_writeback_service.get_provider_for_connection",
|
||||||
|
return_value=fake_provider,
|
||||||
|
):
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/resolution-note/post",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"markdown": "## Problem\nx\n\n## Resolution\nfixed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 502
|
||||||
|
assert "did not verify" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resolve: skip status transition when not configured ────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_skips_status_transition_when_unconfigured(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""No cw_resolved_status_id setting → post the note, don't touch status, not an error."""
|
||||||
|
session = await _make_session(test_db, test_user, with_psa=True)
|
||||||
|
# Deliberately no AccountSettings row.
|
||||||
|
|
||||||
|
fake_provider = AsyncMock()
|
||||||
|
fake_provider.post_note = AsyncMock(return_value=PSANote(
|
||||||
|
id="cw-note-beta", text="...", note_type=NoteType.RESOLUTION, created_at=None,
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.psa_writeback_service.get_provider_for_connection",
|
||||||
|
return_value=fake_provider,
|
||||||
|
):
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/resolution-note/post",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"markdown": "## Problem\nx\n\n## Resolution\nfixed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["outcome"] == "resolved"
|
||||||
|
assert body["verified_status_id"] is None
|
||||||
|
assert body["status_transition_skipped_reason"] is not None
|
||||||
|
fake_provider.update_ticket_status.assert_not_called()
|
||||||
|
fake_provider.get_ticket.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resolve: already-resolved → 409 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_rejects_already_resolved_session(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session = await _make_session(test_db, test_user, with_psa=False)
|
||||||
|
session.status = "resolved"
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/resolution-note/post",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"markdown": "..."},
|
||||||
|
)
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
# ── Escalate: local-only + PSA parallels ────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_escalate_local_only_when_no_psa_ticket(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session = await _make_session(test_db, test_user, with_psa=False)
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/escalation-package/post",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"markdown": "## Problem\nx\n\n## Suggested next steps\n- try X"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["outcome"] == "escalated_local"
|
||||||
|
|
||||||
|
await test_db.refresh(session)
|
||||||
|
assert session.status == "escalated"
|
||||||
|
assert session.escalation_package_markdown is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_escalate_posts_internal_note_to_psa(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""Escalation handoff posts as INTERNAL_ANALYSIS (not customer-visible)."""
|
||||||
|
session = await _make_session(test_db, test_user, with_psa=True)
|
||||||
|
|
||||||
|
fake_provider = AsyncMock()
|
||||||
|
fake_provider.post_note = AsyncMock(return_value=PSANote(
|
||||||
|
id="cw-note-esc", text="...", note_type=NoteType.INTERNAL_ANALYSIS, created_at=None,
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.psa_writeback_service.get_provider_for_connection",
|
||||||
|
return_value=fake_provider,
|
||||||
|
):
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/escalation-package/post",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"markdown": "## Problem\nx\n\n## Suggested next steps\n- try X"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["outcome"] == "escalated"
|
||||||
|
assert body["external_id"] == "cw-note-esc"
|
||||||
|
|
||||||
|
# Handoff packages are internal — must NOT be posted with RESOLUTION or DESCRIPTION flags.
|
||||||
|
called = fake_provider.post_note.await_args.kwargs
|
||||||
|
assert called["note_type"] == NoteType.INTERNAL_ANALYSIS
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
> **Target:** Transform `/assistant` (ResolutionAssist) into the new unified `/pilot` (FlowPilot) surface.
|
> **Target:** Transform `/assistant` (ResolutionAssist) into the new unified `/pilot` (FlowPilot) surface.
|
||||||
> **Audience:** Claude Code (implementation) and Codex (review) reviewed by Michael (owner).
|
> **Audience:** Claude Code (implementation) and Codex (review) reviewed by Michael (owner).
|
||||||
> **Status:** Phases 0–3 implemented and verified end-to-end against the dev stack. Phase 4 next.
|
> **Status:** Phases 0–4 implemented and verified end-to-end against the dev stack. Phase 5 next.
|
||||||
> **Last updated:** April 22, 2026 (Phase 3 — Suggested fix + Resolve preview — committed; live Sonnet preview + state_version cache verified)
|
> **Last updated:** April 22, 2026 (Phase 4 — Resolve + Escalate writebacks — committed; local-only and mocked-PSA paths verified, live CW ticket round-trip pending a test instance)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -770,6 +770,20 @@ git commit -m "feat(pilot): add suggested fix tracking and Resolve note preview
|
|||||||
- Simulate CW silently rejecting a status change — verify the app surfaces an error, not silent success.
|
- Simulate CW silently rejecting a status change — verify the app surfaces an error, not silent success.
|
||||||
- Attempt to Resolve without a linked PSA ticket — session marks resolved locally without erroring; markdown stored in `resolution_note_markdown`.
|
- Attempt to Resolve without a linked PSA ticket — session marks resolved locally without erroring; markdown stored in `resolution_note_markdown`.
|
||||||
|
|
||||||
|
**Verified on 2026-04-22:**
|
||||||
|
- Local-only Resolve + Escalate confirmed end-to-end against the dev stack
|
||||||
|
(no PSA instance wired): markdown stored, session.status flips, 409 on
|
||||||
|
re-post.
|
||||||
|
- Escalation-package preview generates well-formed five-section markdown
|
||||||
|
from a single fact (real Sonnet); second preview call with no state
|
||||||
|
change returns `from_cache=true`, confirming the cache-kind separation
|
||||||
|
from resolution-note previews.
|
||||||
|
- PSA post + status-verification paths covered by mocked-provider pytest
|
||||||
|
cases: happy path, silent-rejection → 502 with clear detail, skipped
|
||||||
|
transition when `cw_resolved_status_id` unset, internal-analysis note
|
||||||
|
type used for escalation handoffs. Live CW round-trip still TODO once a
|
||||||
|
test instance is wired.
|
||||||
|
|
||||||
```
|
```
|
||||||
git commit -m "feat(pilot): wire Resolve and Escalate to ConnectWise writeback with status verification"
|
git commit -m "feat(pilot): wire Resolve and Escalate to ConnectWise writeback with status verification"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ export interface ResolutionNotePreview {
|
|||||||
from_cache: boolean
|
from_cache: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResolutionPostResponse {
|
||||||
|
outcome: 'resolved' | 'escalated' | 'resolved_local' | 'escalated_local'
|
||||||
|
session_status: string
|
||||||
|
external_id: string | null
|
||||||
|
posted_at: string | null
|
||||||
|
verified_status_id: number | null
|
||||||
|
verified_status_name: string | null
|
||||||
|
status_transition_skipped_reason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export const sessionSuggestedFixesApi = {
|
export const sessionSuggestedFixesApi = {
|
||||||
/**
|
/**
|
||||||
* Returns the active suggested fix for a session, or `null` if there isn't one.
|
* Returns the active suggested fix for a session, or `null` if there isn't one.
|
||||||
@@ -79,6 +89,45 @@ export const sessionSuggestedFixesApi = {
|
|||||||
)
|
)
|
||||||
return r.data
|
return r.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parallel to getResolutionNotePreview, for the Escalate flow (five-section
|
||||||
|
* handoff markdown). Separate backend cache kind, same state_version invariant.
|
||||||
|
*/
|
||||||
|
async getEscalationPackagePreview(sessionId: string): Promise<ResolutionNotePreview> {
|
||||||
|
const r = await apiClient.post<ResolutionNotePreview>(
|
||||||
|
`/ai-sessions/${sessionId}/escalation-package/preview`,
|
||||||
|
)
|
||||||
|
return r.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post the engineer-edited resolution markdown to PSA + mark session resolved.
|
||||||
|
* Local-only when the session has no linked PSA ticket (outcome = "resolved_local").
|
||||||
|
*/
|
||||||
|
async postResolutionNote(
|
||||||
|
sessionId: string,
|
||||||
|
markdown: string,
|
||||||
|
resolutionSummary?: string,
|
||||||
|
): Promise<ResolutionPostResponse> {
|
||||||
|
const r = await apiClient.post<ResolutionPostResponse>(
|
||||||
|
`/ai-sessions/${sessionId}/resolution-note/post`,
|
||||||
|
{ markdown, resolution_summary: resolutionSummary },
|
||||||
|
)
|
||||||
|
return r.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async postEscalationPackage(
|
||||||
|
sessionId: string,
|
||||||
|
markdown: string,
|
||||||
|
escalationReason?: string,
|
||||||
|
): Promise<ResolutionPostResponse> {
|
||||||
|
const r = await apiClient.post<ResolutionPostResponse>(
|
||||||
|
`/ai-sessions/${sessionId}/escalation-package/post`,
|
||||||
|
{ markdown, escalation_reason: escalationReason },
|
||||||
|
)
|
||||||
|
return r.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default sessionSuggestedFixesApi
|
export default sessionSuggestedFixesApi
|
||||||
|
|||||||
@@ -1,69 +1,112 @@
|
|||||||
/**
|
/**
|
||||||
* ResolutionNotePreview — Phase 3 popover anchored to the Resolve action area.
|
* ResolutionNotePreview — Phase 3/4 popover for the Resolve AND Escalate flows.
|
||||||
*
|
*
|
||||||
* Persistent (not modal) popover showing the four-section draft markdown that
|
* Persistent (not modal) popover showing the draft markdown that would be
|
||||||
* would be posted to the customer ticket on Resolve. Per FLOWPILOT-MIGRATION.md
|
* posted to the customer ticket on Confirm. Phase 3 was read-only; Phase 4
|
||||||
* Section 3.1, the engineer reviews/edits the draft inline and Confirm & post
|
* adds inline editing + a "Confirm & post" action that writes to PSA and
|
||||||
* fires the PSA writeback (wired in Phase 4 — for now this is read-only).
|
* transitions the session state.
|
||||||
*
|
*
|
||||||
* Refresh policy: parent triggers `onRefresh` when state_version changes.
|
* Kind switches the labels, button colors, and confirm-CTA text — the
|
||||||
* Backend caches by state_version, so repeat fetches are cheap (no Sonnet
|
* underlying mechanics (preview fetch + edit + post) are identical.
|
||||||
* call) when no facts/fixes/scripts have changed.
|
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Loader2, RefreshCw, X, FileText } from 'lucide-react'
|
import { Loader2, RefreshCw, X, FileText, Pencil, Check, ArrowUpRight } from 'lucide-react'
|
||||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import type { ResolutionNotePreview as PreviewData } from '@/api/sessionSuggestedFixes'
|
import type { ResolutionNotePreview as PreviewData } from '@/api/sessionSuggestedFixes'
|
||||||
|
|
||||||
|
export type PreviewKind = 'resolve' | 'escalate'
|
||||||
|
|
||||||
interface ResolutionNotePreviewProps {
|
interface ResolutionNotePreviewProps {
|
||||||
|
kind: PreviewKind
|
||||||
open: boolean
|
open: boolean
|
||||||
loading: boolean
|
loading: boolean
|
||||||
preview: PreviewData | null
|
preview: PreviewData | null
|
||||||
error: string | null
|
error: string | null
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onRefresh: () => Promise<void> | void
|
onRefresh: () => Promise<void> | void
|
||||||
|
onConfirm: (markdown: string) => Promise<void>
|
||||||
|
posting: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResolutionNotePreview({
|
export function ResolutionNotePreview({
|
||||||
|
kind,
|
||||||
open,
|
open,
|
||||||
loading,
|
loading,
|
||||||
preview,
|
preview,
|
||||||
error,
|
error,
|
||||||
onClose,
|
onClose,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
onConfirm,
|
||||||
|
posting,
|
||||||
}: ResolutionNotePreviewProps) {
|
}: ResolutionNotePreviewProps) {
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
|
||||||
|
// Keep the draft textarea in sync whenever fresh markdown arrives and we
|
||||||
|
// aren't in the middle of editing. Once the engineer edits, their changes
|
||||||
|
// win — we don't blow them away on a refetch.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing && preview?.markdown) {
|
||||||
|
setDraft(preview.markdown)
|
||||||
|
}
|
||||||
|
}, [preview?.markdown, editing])
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
|
const label = kind === 'resolve' ? 'Resolution note' : 'Escalation handoff package'
|
||||||
|
const confirmLabel = kind === 'resolve' ? 'Confirm & post to PSA' : 'Confirm & escalate'
|
||||||
|
const confirmButtonTone = kind === 'resolve'
|
||||||
|
? 'bg-success text-bg-page hover:bg-success/90'
|
||||||
|
: 'bg-warning text-bg-page hover:bg-warning/90'
|
||||||
|
const KindIcon = kind === 'resolve' ? FileText : ArrowUpRight
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
try { await onRefresh() } finally { setRefreshing(false) }
|
try { await onRefresh() } finally { setRefreshing(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!draft.trim()) return
|
||||||
|
await onConfirm(draft)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// The popover is positioned absolutely against its anchor by the parent.
|
|
||||||
// We render full-width inside the task lane below the Resolve action bar.
|
|
||||||
<div className="rounded-lg border border-default bg-elevated/30 mx-3 mb-3 overflow-hidden shadow-lg">
|
<div className="rounded-lg border border-default bg-elevated/30 mx-3 mb-3 overflow-hidden shadow-lg">
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-default bg-bg-page">
|
<div className="flex items-center justify-between px-3 py-2 border-b border-default bg-bg-page">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileText size={13} className="text-accent" />
|
<KindIcon size={13} className={kind === 'resolve' ? 'text-success' : 'text-warning'} />
|
||||||
<span className="text-[0.75rem] font-semibold text-heading">
|
<span className="text-[0.75rem] font-semibold text-heading">
|
||||||
Resolution note preview
|
{label} preview
|
||||||
</span>
|
</span>
|
||||||
{preview?.target_ticket_ref && (
|
{preview?.target_ticket_ref && (
|
||||||
<span className="text-[0.6875rem] font-mono text-accent-text">
|
<span className="text-[0.6875rem] font-mono text-accent-text">
|
||||||
→ {preview.target_ticket_ref}
|
→ {preview.target_ticket_ref}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{!preview?.target_ticket_ref && (
|
||||||
|
<span className="text-[0.6875rem] italic text-muted-foreground">
|
||||||
|
local only · no PSA ticket linked
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{preview?.from_cache && (
|
{preview?.from_cache && (
|
||||||
<span className="text-[0.6875rem] text-muted-foreground italic">cached</span>
|
<span className="text-[0.6875rem] text-muted-foreground italic">cached</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{!editing && preview && (
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors"
|
||||||
|
title="Edit before posting"
|
||||||
|
>
|
||||||
|
<Pencil size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={refreshing || loading}
|
disabled={refreshing || loading || editing || posting}
|
||||||
className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors disabled:opacity-40"
|
className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors disabled:opacity-40"
|
||||||
title="Refresh preview"
|
title="Refresh preview"
|
||||||
>
|
>
|
||||||
@@ -71,6 +114,7 @@ export function ResolutionNotePreview({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
disabled={posting}
|
||||||
className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors"
|
className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors"
|
||||||
title="Close preview"
|
title="Close preview"
|
||||||
>
|
>
|
||||||
@@ -83,15 +127,20 @@ export function ResolutionNotePreview({
|
|||||||
{loading && !preview && (
|
{loading && !preview && (
|
||||||
<div className="flex items-center gap-2 text-[0.75rem] text-muted-foreground">
|
<div className="flex items-center gap-2 text-[0.75rem] text-muted-foreground">
|
||||||
<Loader2 size={12} className="animate-spin" />
|
<Loader2 size={12} className="animate-spin" />
|
||||||
Drafting note from session state...
|
Drafting {label.toLowerCase()} from session state...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && <div className="text-[0.75rem] text-danger">{error}</div>}
|
||||||
<div className="text-[0.75rem] text-danger">{error}</div>
|
{preview && editing && (
|
||||||
|
<textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-default bg-input px-2.5 py-2 text-[0.8125rem] text-heading font-mono resize-y min-h-[240px] max-h-[40vh] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{preview && (
|
{preview && !editing && (
|
||||||
<div className="prose prose-invert prose-sm max-w-none text-[0.8125rem] leading-relaxed">
|
<div className="prose prose-invert prose-sm max-w-none text-[0.8125rem] leading-relaxed">
|
||||||
<MarkdownContent content={preview.markdown} />
|
<MarkdownContent content={draft || preview.markdown} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!loading && !error && !preview && (
|
{!loading && !error && !preview && (
|
||||||
@@ -100,6 +149,35 @@ export function ResolutionNotePreview({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-default bg-bg-page">
|
||||||
|
{editing ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(false)}
|
||||||
|
disabled={posting}
|
||||||
|
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
||||||
|
>
|
||||||
|
Done editing
|
||||||
|
</button>
|
||||||
|
) : <span />}
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={posting || !draft.trim()}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[0.75rem] font-semibold transition-colors',
|
||||||
|
confirmButtonTone,
|
||||||
|
(posting || !draft.trim()) && 'opacity-50 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{posting ? (
|
||||||
|
<><Loader2 size={11} className="animate-spin" /> Posting...</>
|
||||||
|
) : (
|
||||||
|
<><Check size={11} /> {confirmLabel}</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,16 +87,19 @@ export default function AssistantChatPage() {
|
|||||||
// selectChat and after each chat send (the AI may have emitted [PROMOTE]
|
// selectChat and after each chat send (the AI may have emitted [PROMOTE]
|
||||||
// markers that synthesized new facts server-side).
|
// markers that synthesized new facts server-side).
|
||||||
const [facts, setFacts] = useState<SessionFact[]>([])
|
const [facts, setFacts] = useState<SessionFact[]>([])
|
||||||
// Phase 3: active suggested fix + resolution-note preview state.
|
// Phase 3: active suggested fix; Phase 4 extends the preview popover to
|
||||||
|
// support both Resolve and Escalate (kind-parameterized, one active at a time).
|
||||||
const [activeFix, setActiveFix] = useState<SessionSuggestedFix | null>(null)
|
const [activeFix, setActiveFix] = useState<SessionSuggestedFix | null>(null)
|
||||||
const [previewOpen, setPreviewOpen] = useState(false)
|
const [previewKind, setPreviewKind] = useState<'resolve' | 'escalate' | null>(null)
|
||||||
const [previewData, setPreviewData] = useState<ResolutionNotePreviewData | null>(null)
|
const [previewData, setPreviewData] = useState<ResolutionNotePreviewData | null>(null)
|
||||||
const [previewLoading, setPreviewLoading] = useState(false)
|
const [previewLoading, setPreviewLoading] = useState(false)
|
||||||
const [previewError, setPreviewError] = useState<string | null>(null)
|
const [previewError, setPreviewError] = useState<string | null>(null)
|
||||||
|
const [previewPosting, setPreviewPosting] = useState(false)
|
||||||
// Debounce timer for preview refresh — Phase 3 spec calls for 500ms client-
|
// Debounce timer for preview refresh — Phase 3 spec calls for 500ms client-
|
||||||
// side debounce so rapid edits don't fan out to the LLM (cache absorbs the
|
// side debounce so rapid edits don't fan out to the LLM (cache absorbs the
|
||||||
// dups, but the request itself still costs HTTP RTT).
|
// dups, but the request itself still costs HTTP RTT).
|
||||||
const previewDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const previewDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const previewOpen = previewKind !== null
|
||||||
const [showOverflow, setShowOverflow] = useState(false)
|
const [showOverflow, setShowOverflow] = useState(false)
|
||||||
const toggleSidebarCollapse = () => {
|
const toggleSidebarCollapse = () => {
|
||||||
const next = !sidebarCollapsed
|
const next = !sidebarCollapsed
|
||||||
@@ -281,11 +284,18 @@ export default function AssistantChatPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const refreshPreview = useCallback(async (chatId: string) => {
|
// Kind-aware preview fetch: Resolve hits /resolution-note/preview,
|
||||||
|
// Escalate hits /escalation-package/preview. They're cached separately
|
||||||
|
// on the backend, so switching kinds never returns stale markdown.
|
||||||
|
const refreshPreview = useCallback(async (chatId: string, kind?: 'resolve' | 'escalate') => {
|
||||||
|
const effectiveKind = kind ?? previewKind
|
||||||
|
if (!effectiveKind) return
|
||||||
setPreviewLoading(true)
|
setPreviewLoading(true)
|
||||||
setPreviewError(null)
|
setPreviewError(null)
|
||||||
try {
|
try {
|
||||||
const p = await sessionSuggestedFixesApi.getResolutionNotePreview(chatId)
|
const p = effectiveKind === 'resolve'
|
||||||
|
? await sessionSuggestedFixesApi.getResolutionNotePreview(chatId)
|
||||||
|
: await sessionSuggestedFixesApi.getEscalationPackagePreview(chatId)
|
||||||
if (currentChatRef.current !== chatId) return
|
if (currentChatRef.current !== chatId) return
|
||||||
setPreviewData(p)
|
setPreviewData(p)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -298,7 +308,7 @@ export default function AssistantChatPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setPreviewLoading(false)
|
setPreviewLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [previewKind])
|
||||||
|
|
||||||
// Trigger preview refresh with a 500ms debounce. The backend cache short-
|
// Trigger preview refresh with a 500ms debounce. The backend cache short-
|
||||||
// circuits same-state calls, but the network round-trip is still avoidable
|
// circuits same-state calls, but the network round-trip is still avoidable
|
||||||
@@ -365,13 +375,61 @@ export default function AssistantChatPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTogglePreview = () => {
|
const handleOpenPreview = (kind: 'resolve' | 'escalate') => {
|
||||||
if (!activeChatId) return
|
if (!activeChatId) return
|
||||||
const next = !previewOpen
|
// Opening a different kind clobbers the cached markdown so the popover
|
||||||
setPreviewOpen(next)
|
// doesn't flash stale content while the new kind fetches.
|
||||||
if (next && !previewData) {
|
if (previewKind !== kind) setPreviewData(null)
|
||||||
// First open — fetch immediately, no debounce.
|
setPreviewKind(kind)
|
||||||
refreshPreview(activeChatId)
|
setPreviewError(null)
|
||||||
|
refreshPreview(activeChatId, kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClosePreview = () => {
|
||||||
|
setPreviewKind(null)
|
||||||
|
setPreviewError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmPost = async (markdown: string) => {
|
||||||
|
if (!activeChatId || !previewKind) return
|
||||||
|
setPreviewPosting(true)
|
||||||
|
try {
|
||||||
|
const out = previewKind === 'resolve'
|
||||||
|
? await sessionSuggestedFixesApi.postResolutionNote(activeChatId, markdown)
|
||||||
|
: await sessionSuggestedFixesApi.postEscalationPackage(activeChatId, markdown)
|
||||||
|
setActiveSessionStatus(out.session_status)
|
||||||
|
|
||||||
|
if (out.outcome === 'resolved') {
|
||||||
|
toast.success(
|
||||||
|
out.verified_status_id
|
||||||
|
? `Posted to ${previewData?.target_ticket_ref ?? 'PSA'} · status ${out.verified_status_name}`
|
||||||
|
: `Posted to ${previewData?.target_ticket_ref ?? 'PSA'}${out.status_transition_skipped_reason ? ' · status unchanged' : ''}`,
|
||||||
|
)
|
||||||
|
} else if (out.outcome === 'escalated') {
|
||||||
|
toast.success(
|
||||||
|
out.verified_status_id
|
||||||
|
? `Escalated · ${previewData?.target_ticket_ref ?? 'PSA'} status ${out.verified_status_name}`
|
||||||
|
: `Escalated · handoff posted to ${previewData?.target_ticket_ref ?? 'PSA'}`,
|
||||||
|
)
|
||||||
|
} else if (out.outcome === 'resolved_local') {
|
||||||
|
toast.success('Session resolved locally (no PSA ticket linked)')
|
||||||
|
} else if (out.outcome === 'escalated_local') {
|
||||||
|
toast.success('Session escalated locally (no PSA ticket linked)')
|
||||||
|
}
|
||||||
|
handleClosePreview()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('[AssistantChat] confirm post failed:', err)
|
||||||
|
const status = (err as { response?: { status?: number }; response?: { data?: { detail?: string } } })?.response?.status
|
||||||
|
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
||||||
|
if (status === 502) {
|
||||||
|
toast.error(detail || 'PSA posted partially — see server logs.')
|
||||||
|
} else if (status === 409) {
|
||||||
|
toast.warning(detail || 'Session is already in that state.')
|
||||||
|
} else {
|
||||||
|
toast.error('Could not post. Please try again.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPreviewPosting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +446,7 @@ export default function AssistantChatPage() {
|
|||||||
setActiveFix(null)
|
setActiveFix(null)
|
||||||
setPreviewData(null)
|
setPreviewData(null)
|
||||||
setPreviewError(null)
|
setPreviewError(null)
|
||||||
setPreviewOpen(false)
|
setPreviewKind(null)
|
||||||
// Fire facts + active-fix fetches in parallel with session detail.
|
// Fire facts + active-fix fetches in parallel with session detail.
|
||||||
refreshSessionDerived(chatId)
|
refreshSessionDerived(chatId)
|
||||||
try {
|
try {
|
||||||
@@ -1207,20 +1265,42 @@ export default function AssistantChatPage() {
|
|||||||
}
|
}
|
||||||
bottomSlot={
|
bottomSlot={
|
||||||
<>
|
<>
|
||||||
<button
|
<div className="flex items-center gap-3 px-3 mt-1">
|
||||||
onClick={handleTogglePreview}
|
<button
|
||||||
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-accent-text hover:text-heading transition-colors px-3 mt-1"
|
onClick={() => handleOpenPreview('resolve')}
|
||||||
>
|
className={cn(
|
||||||
<FileText size={12} />
|
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
|
||||||
{previewOpen ? 'Hide' : 'Preview'} Resolve note
|
previewKind === 'resolve'
|
||||||
</button>
|
? 'text-success'
|
||||||
|
: 'text-accent-text hover:text-heading',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FileText size={12} />
|
||||||
|
{previewKind === 'resolve' ? 'Showing' : 'Preview'} Resolve note
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenPreview('escalate')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
|
||||||
|
previewKind === 'escalate'
|
||||||
|
? 'text-warning'
|
||||||
|
: 'text-muted-foreground hover:text-heading',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ArrowUpRight size={12} />
|
||||||
|
{previewKind === 'escalate' ? 'Showing' : 'Escalate instead'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<ResolutionNotePreviewPopover
|
<ResolutionNotePreviewPopover
|
||||||
|
kind={previewKind ?? 'resolve'}
|
||||||
open={previewOpen}
|
open={previewOpen}
|
||||||
loading={previewLoading}
|
loading={previewLoading}
|
||||||
preview={previewData}
|
preview={previewData}
|
||||||
error={previewError}
|
error={previewError}
|
||||||
onClose={() => setPreviewOpen(false)}
|
onClose={handleClosePreview}
|
||||||
onRefresh={() => activeChatId && refreshPreview(activeChatId)}
|
onRefresh={() => activeChatId && refreshPreview(activeChatId)}
|
||||||
|
onConfirm={handleConfirmPost}
|
||||||
|
posting={previewPosting}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user