feat(pilot): Phase 5 — inline Script Generator integration
All checks were successful
Mirror to GitHub / mirror (push) Successful in 10s
All checks were successful
Mirror to GitHub / mirror (push) Successful in 10s
Wires the SuggestedFix card to an inline panel that handles both cases:
template-matched fixes open the Script Library generator with parameters
pre-filled from session context; un-matched fixes open the three-option
dialog (one_off / draft_template / build_template). The decision endpoint
records the path choice with side effects: draft_template persists a
draft_templates row via a Sonnet-driven TemplateExtractionService;
build_template returns a redirect to the Script Builder; one_off just
records the choice.
Backend:
- TemplateExtractionService: drafts a parameter schema from a concrete
rendered script. Conservative by default ("prefer fewer parameters").
Round-trip-validates that templated_body only references declared
parameters; missing-key mismatch falls back to the original script
with no params. LLM/parse failures fall back identically — the
engineer can still create a draft and refine in the post-resolve
prompt (Phase 6).
- /suggested-fixes/{fix_id}/decision side effects:
* one_off → returns rendered_script (engineer's edited version or the
fix's ai_drafted_script verbatim)
* draft_template → same + creates draft_templates row with extracted
params, returns draft_template_id
* build_template → returns redirect_path=/scripts/builder?from_session=
&fix= so the frontend can navigate to the builder pre-loaded
- 400 when a non-template fix has no ai_drafted_script (template-matched
fixes take the dedicated /scripts/generate path, not this endpoint).
- 12 tests: TemplateExtractionService parse + fallback paths, all four
decision branches, edited_script override, missing-script 400.
Frontend:
- src/components/pilot/script/{TemplateMatchPanel, NoTemplateDialog,
ParameterizationPreview}.tsx — inline panels rendered in the task
lane's bottom slot when the engineer clicks a SuggestedFix card.
- TemplateMatchPanel: loads template via /scripts/templates/{id},
pre-fills params from fix.ai_drafted_parameters with cyan "from
session" tags, generates via existing /scripts/generate (already
bumps state_version on ai_session_id from Phase 3). 404 falls back
with a clear message instead of erroring.
- NoTemplateDialog: shows the AI-drafted script with proposed parameter
values highlighted in amber via ParameterizationPreview; three option
cards with the middle (draft_template) flagged Recommended; inline
edit on the script body before deciding.
- SuggestedFix card now clickable: onActivate toggles the inline panel.
- AssistantChatPage: scriptPanelOpen state + handleScriptDecision that
navigates on build_template and toasts on the other paths. Active fix
changes auto-close the panel so engineers don't act on stale state.
- Cmd+K → "Open inline Script Generator" palette entry surfaces only on
/pilot/:id routes; fires a window event the chat page subscribes to.
No Resolve shortcut added per Section 14 decision (browser ⌘R conflict).
Verified 2026-04-22 against the dev stack:
- one_off / draft_template / build_template all return the right shape
with real Sonnet TemplateExtractionService for the draft path.
- Conservative extraction confirmed: cmdkey + Restart-Process script
yielded zero proposed parameters as intended.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user