"""Pydantic schemas for session suggested fixes (Phase 3). See FLOWPILOT-MIGRATION.md Section 5.2. """ from __future__ import annotations from datetime import datetime from typing import Any, Literal from uuid import UUID from pydantic import BaseModel, Field UserDecision = Literal["one_off", "draft_template", "build_template", "dismissed"] # "dismissed" here is the outcome dimension — orthogonal to UserDecision's # "dismissed" (script-path choice), though the migration backfill aligns # them for pre-existing rows. FixStatus = Literal[ "proposed", "applied_success", "applied_failed", "applied_partial", "applied_pending", "dismissed", ] class SessionSuggestedFixResponse(BaseModel): id: UUID session_id: UUID title: str description: str confidence_pct: int script_template_id: UUID | None ai_drafted_script: str | None ai_drafted_parameters: dict[str, Any] | None user_decision: UserDecision | None superseded_at: datetime | None created_at: datetime status: FixStatus applied_at: datetime | None verified_at: datetime | None partial_notes: str | None pending_reason: str | None failure_reason: str | None ai_outcome_proposal: dict[str, Any] | None model_config = {"from_attributes": True} class SessionSuggestedFixDecisionRequest(BaseModel): """Engineer's path choice on a suggested fix. Server-side side effects per Section 5.2: - one_off: record decision, return the rendered (AI-drafted or engineer-edited) script. No persistent library artifact created. - draft_template: same as one_off, plus TemplateExtractionService proposes a parameterization and a draft_templates row is created. - build_template: return a redirect payload pointing at the Script Builder page, pre-loaded with the drafted script body. - dismissed: mark the fix superseded. For one_off / draft_template, the engineer may have edited the drafted script or its parameters in the dialog. The final versions are sent back here so we persist what will actually run. """ decision: UserDecision # Present for one_off / draft_template — the engineer's final version of # the drafted script after any inline edits. Omit to use the fix's # `ai_drafted_script` verbatim. edited_script: str | None = Field(None, min_length=1, max_length=50_000) # Parameter values used when rendering (informational, stored on the # draft_template row so a reviewer can see what the first run used). parameters_used: dict[str, Any] | None = None class SessionSuggestedFixDecisionResponse(BaseModel): """Returned after recording a decision.""" id: UUID user_decision: UserDecision # Populated for one_off / draft_template — the script to display/run. rendered_script: str | None = None # Populated for draft_template — the ID of the draft_templates row so # the post-resolve TemplatizePrompt can fetch it in Phase 6. draft_template_id: UUID | None = None # Populated for build_template — where to send the engineer next. redirect_path: str | None = Field( None, description="Where to send the engineer next (e.g. /scripts/builder?... for build_template)", ) # Subset of FixStatus that the engineer can set via the outcome endpoint — # `proposed` is excluded because you can't un-decide a fix back to "proposed". FixOutcome = Literal[ "applied_success", "applied_failed", "applied_partial", "applied_pending", "dismissed", ] class SessionSuggestedFixOutcomeRequest(BaseModel): """Engineer-reported outcome of applying a suggested fix. Writes to session_suggested_fixes.status and companion columns. This is orthogonal to `user_decision` (which records which script-path the engineer took); outcome captures whether the fix actually worked. Allowed transitions: - from `proposed`, `applied_partial`, or `applied_pending`: any outcome is valid. Partial means "did some of it"; pending means "did all of it but verification is deferred (waiting on client, async sync, etc)". Both are parked, not terminal — the engineer may advance them to success/failed/dismiss. - from any terminal outcome (`applied_success`, `applied_failed`, `dismissed`): server returns 409 """ outcome: FixOutcome # Required for applied_partial AND applied_pending; optional for # applied_failed; ignored otherwise. For pending, this is the # "what are you waiting on?" reason (e.g. "client power-cycling router"). notes: str | None = Field(None, max_length=500) class SessionSuggestedFixScriptRequest(BaseModel): """Engineer-submitted drafted script for a suggested fix. Called when the inline Script Builder tab's Submit action fires. The fix must be non-terminal (still proposed/applied_partial). Setting the script does NOT stamp applied_at — a draft is not an application. """ ai_drafted_script: str = Field(..., min_length=1, max_length=50_000) ai_drafted_parameters: dict[str, Any] | None = None # ── Resolution note preview ──────────────────────────────────────────────── class ResolutionNotePreviewResponse(BaseModel): markdown: str target_ticket_ref: str | None state_version: int 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