diff --git a/backend/app/api/endpoints/session_suggested_fixes.py b/backend/app/api/endpoints/session_suggested_fixes.py index 9243ad5d..d18b8f18 100644 --- a/backend/app/api/endpoints/session_suggested_fixes.py +++ b/backend/app/api/endpoints/session_suggested_fixes.py @@ -32,6 +32,8 @@ from app.schemas.session_suggested_fix import ( SessionSuggestedFixDecisionResponse, SessionSuggestedFixResponse, ) +from app.models.draft_template import DraftTemplate +from app.models.session_fact import SessionFact from app.services.escalation_package_generator import EscalationPackageGeneratorService from app.services.preview_cache import preview_cache from app.services.psa_writeback_service import ( @@ -39,6 +41,7 @@ from app.services.psa_writeback_service import ( PSAWritebackService, ) from app.services.resolution_note_generator import ResolutionNoteGeneratorService +from app.services.template_extraction_service import extract_parameters as _extract_template_parameters logger = logging.getLogger(__name__) @@ -105,12 +108,13 @@ async def record_decision( ) -> SessionSuggestedFixDecisionResponse: """Record the engineer's path choice on a suggested fix. - Phase 3 only persists the decision and (for `dismissed`) supersedes the - row. Side effects — script generation for `one_off` / `draft_template`, - redirect for `build_template` — land in Phase 5 alongside the inline - Script Generator integration. The response shape is forward-compatible. + Phase 3 recorded the choice and (for `dismissed`) superseded the fix. + Phase 5 adds side effects: one_off / draft_template return the rendered + script; draft_template also creates a `draft_templates` row via the + TemplateExtractionService; build_template returns a redirect to the + Script Builder. """ - await _load_session_or_404(db, session_id) + session_obj = await _load_session_or_404(db, session_id) result = await db.execute( select(SessionSuggestedFix).where( @@ -145,15 +149,97 @@ async def record_decision( .where(AISession.id == session_id) .values(state_version=AISession.state_version + 1) ) + + rendered_script: str | None = None + draft_template_id: UUID | None = None + redirect_path: str | None = None + + # Phase 5 side effects. All three non-dismiss paths assume the fix has + # either a script_template_id (template match — use the dedicated + # /scripts/generate endpoint from the frontend, not this one) or an + # ai_drafted_script (custom script — this is the entry point). + if body.decision in ("one_off", "draft_template", "build_template"): + drafted = body.edited_script or fix.ai_drafted_script + if not drafted: + # Template-matched fixes take the regular /scripts/generate path. + # If a fix somehow reaches here without a drafted script AND + # without a template, that's a client-side wiring bug. + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + "Suggested fix has no ai_drafted_script — use " + "/api/v1/scripts/generate for template-matched fixes." + ), + ) + rendered_script = drafted.strip() + + if body.decision == "draft_template": + # TemplateExtractionService proposes the parameterization. Runs + # under the same transaction so a failure rolls back the decision. + session_ctx = await _summarize_session_for_extraction(db, session_id) + extraction = await _extract_template_parameters( + script_body=rendered_script or "", + session_context=session_ctx, + ticket_context=None, # ticket context wiring lands in Phase 5 polish + ) + + draft = DraftTemplate( + account_id=session_obj.account_id, + source_session_id=session_obj.id, + source_user_id=current_user.id, + script_body=extraction["templated_body"] or (rendered_script or ""), + proposed_parameters={"parameters": extraction["parameters"]}, + proposed_name=fix.title[:200] if fix.title else None, + status="pending", + ) + db.add(draft) + await db.flush() + draft_template_id = draft.id + + if body.decision == "build_template": + # Frontend navigates to the Script Builder preloaded with the + # drafted body. The builder wires the full parameterization flow; + # we hand it a scratch-pad query string, not persistent state. + redirect_path = ( + f"/scripts/builder?from_session={session_obj.id}&fix={fix.id}" + ) + await db.commit() await db.refresh(fix) return SessionSuggestedFixDecisionResponse( id=fix.id, user_decision=fix.user_decision, # type: ignore[arg-type] + rendered_script=rendered_script, + draft_template_id=draft_template_id, + redirect_path=redirect_path, ) +async def _summarize_session_for_extraction( + db: AsyncSession, session_id: UUID, +) -> str: + """Compact fact list for TemplateExtractionService context. + + We don't send the full chat transcript — the extractor only needs enough + signal to decide which values in the script are session-specific (and + therefore worth parameterizing). + """ + result = await db.execute( + select(SessionFact) + .where( + SessionFact.session_id == session_id, + SessionFact.deleted_at.is_(None), + ) + .order_by(SessionFact.created_at.asc()) + ) + facts = list(result.scalars().all()) + if not facts: + return "" + lines = [f"- {f.text}" for f in facts] + return "\n".join(lines) + + # ── Resolution note preview ──────────────────────────────────────────────── @router.post( diff --git a/backend/app/core/config.py b/backend/app/core/config.py index be180b97..0363bf8e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -142,6 +142,10 @@ class Settings(BaseSettings): # FlowPilot migration Phase 4 — escalation handoff package. Parallel # to resolution_note: Sonnet, same cache story, no MCP. "escalation_package": "standard", + # FlowPilot migration Phase 5 — extract a parameter schema from a + # concrete rendered script so a draft_template can be proposed. + # Creates a persistent library artifact on accept, so Sonnet. + "template_extraction": "standard", } def get_model_for_action(self, action_type: str) -> str: diff --git a/backend/app/schemas/session_suggested_fix.py b/backend/app/schemas/session_suggested_fix.py index afc315e9..df55fc1f 100644 --- a/backend/app/schemas/session_suggested_fix.py +++ b/backend/app/schemas/session_suggested_fix.py @@ -33,21 +33,38 @@ class SessionSuggestedFixDecisionRequest(BaseModel): """Engineer's path choice on a suggested fix. Server-side side effects per Section 5.2: - - one_off: render the script (Phase 5), no template created. - - draft_template: render + queue a draft_templates row (Phase 5/6). - - build_template: redirect to full template creation (Phase 5). - - dismissed: mark the fix superseded so a fresh suggestion can take over. + - 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; richer payloads land in Phase 5.""" + """Returned after recording a decision.""" id: UUID user_decision: UserDecision - # Set when the decision triggered side effects (e.g. a script generation). - # Phase 3 only records the choice; this stays None until Phase 5 wires it. + # 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)", diff --git a/backend/app/services/template_extraction_service.py b/backend/app/services/template_extraction_service.py new file mode 100644 index 00000000..0f90ca06 --- /dev/null +++ b/backend/app/services/template_extraction_service.py @@ -0,0 +1,201 @@ +"""TemplateExtractionService — propose a parameter schema from a rendered script. + +Phase 5 of the FlowPilot migration. Called when an engineer chooses +"Run now, templatize after resolve" on a suggested fix with no existing +library match. The service looks at the concrete script (with the values +the engineer is about to run with) and session/ticket context, then +proposes a parameterization that future engineers could use from the +Script Library. + +Design choices (per FLOWPILOT-MIGRATION.md Section 6.4): + +- **Conservative by default.** Prefer fewer parameters. Environment-agnostic + values (like a command name) should not be parameterized. The prompt calls + that out explicitly. +- **Round-trip check.** After the LLM proposes parameters, we validate that + the templated body renders back to the original script when given the + extracted parameter values. Failures log a warning and the caller falls + back to a single-parameter "raw script" proposal. +- **Model:** Sonnet (`template_extraction` tier). Creates a persistent + library artifact — quality matters more than latency. + +Output shape mirrors the Script Generator's parameter schema: + { + "parameters": [ + {"key": "", "label": "", "type": "text|password|select|...", + "inferred_from": ""} + ], + "templated_body": "