feat(pilot): Phase 5 — inline Script Generator integration
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:
2026-04-22 00:15:29 -04:00
parent 8fd2c1bac6
commit fa61376303
13 changed files with 1368 additions and 24 deletions

View File

@@ -32,6 +32,8 @@ from app.schemas.session_suggested_fix import (
SessionSuggestedFixDecisionResponse, SessionSuggestedFixDecisionResponse,
SessionSuggestedFixResponse, 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.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 ( from app.services.psa_writeback_service import (
@@ -39,6 +41,7 @@ from app.services.psa_writeback_service import (
PSAWritebackService, PSAWritebackService,
) )
from app.services.resolution_note_generator import ResolutionNoteGeneratorService 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__) logger = logging.getLogger(__name__)
@@ -105,12 +108,13 @@ async def record_decision(
) -> SessionSuggestedFixDecisionResponse: ) -> SessionSuggestedFixDecisionResponse:
"""Record the engineer's path choice on a suggested fix. """Record the engineer's path choice on a suggested fix.
Phase 3 only persists the decision and (for `dismissed`) supersedes the Phase 3 recorded the choice and (for `dismissed`) superseded the fix.
row. Side effects — script generation for `one_off` / `draft_template`, Phase 5 adds side effects: one_off / draft_template return the rendered
redirect for `build_template` — land in Phase 5 alongside the inline script; draft_template also creates a `draft_templates` row via the
Script Generator integration. The response shape is forward-compatible. 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( result = await db.execute(
select(SessionSuggestedFix).where( select(SessionSuggestedFix).where(
@@ -145,15 +149,97 @@ async def record_decision(
.where(AISession.id == session_id) .where(AISession.id == session_id)
.values(state_version=AISession.state_version + 1) .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.commit()
await db.refresh(fix) await db.refresh(fix)
return SessionSuggestedFixDecisionResponse( return SessionSuggestedFixDecisionResponse(
id=fix.id, id=fix.id,
user_decision=fix.user_decision, # type: ignore[arg-type] 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 ──────────────────────────────────────────────── # ── Resolution note preview ────────────────────────────────────────────────
@router.post( @router.post(

View File

@@ -142,6 +142,10 @@ class Settings(BaseSettings):
# FlowPilot migration Phase 4 — escalation handoff package. Parallel # FlowPilot migration Phase 4 — escalation handoff package. Parallel
# to resolution_note: Sonnet, same cache story, no MCP. # to resolution_note: Sonnet, same cache story, no MCP.
"escalation_package": "standard", "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: def get_model_for_action(self, action_type: str) -> str:

View File

@@ -33,21 +33,38 @@ class SessionSuggestedFixDecisionRequest(BaseModel):
"""Engineer's path choice on a suggested fix. """Engineer's path choice on a suggested fix.
Server-side side effects per Section 5.2: Server-side side effects per Section 5.2:
- one_off: render the script (Phase 5), no template created. - one_off: record decision, return the rendered (AI-drafted or
- draft_template: render + queue a draft_templates row (Phase 5/6). engineer-edited) script. No persistent library artifact created.
- build_template: redirect to full template creation (Phase 5). - draft_template: same as one_off, plus TemplateExtractionService
- dismissed: mark the fix superseded so a fresh suggestion can take over. 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 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): class SessionSuggestedFixDecisionResponse(BaseModel):
"""Returned after recording a decision; richer payloads land in Phase 5.""" """Returned after recording a decision."""
id: UUID id: UUID
user_decision: UserDecision user_decision: UserDecision
# Set when the decision triggered side effects (e.g. a script generation). # Populated for one_off / draft_template — the script to display/run.
# Phase 3 only records the choice; this stays None until Phase 5 wires it.
rendered_script: str | None = None 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( redirect_path: str | None = Field(
None, None,
description="Where to send the engineer next (e.g. /scripts/builder?... for build_template)", description="Where to send the engineer next (e.g. /scripts/builder?... for build_template)",

View File

@@ -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": "<snake>", "label": "<human>", "type": "text|password|select|...",
"inferred_from": "<session fact / ticket field / ai guess>"}
],
"templated_body": "<script with {{ key }} placeholders>",
}
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from app.core.ai_provider import get_ai_provider
from app.core.config import settings
logger = logging.getLogger(__name__)
_EXTRACTION_SYSTEM_PROMPT = """\
You are a senior MSP engineer drafting a reusable script template from a \
concrete script that resolved one ticket. Your job is to identify the values \
in the script that would change for a different invocation — those become \
parameters — and replace them with {{ snake_case }} placeholders.
Return strict JSON with this shape:
{
"parameters": [
{
"key": "<snake_case, ASCII>",
"label": "<Short human label, Title Case>",
"type": "text" | "password" | "select" | "boolean" | "number" | "textarea",
"inferred_from": "<short sentence naming the session fact or ticket \
field this value came from; or 'ai best-guess' when neither>"
}
],
"templated_body": "<the original script with each parameterized value \
replaced by {{ key }} matching the parameters above>"
}
Rules:
- Prefer FEWER parameters. If a value looks environment-agnostic — a cmdlet \
name, a standard path like C:\\Windows\\System32, a Microsoft-documented URL \
— keep it hardcoded.
- Secret-looking values (passwords, API keys, client secrets) MUST be \
parameterized with type=password.
- The templated_body MUST render back to the original script when the \
parameter values from the context are substituted in. Preserve all whitespace, \
comments, and casing.
- If the script has no meaningful parameters (e.g. it's a single read-only \
cmdlet like Get-Service), return parameters=[] and templated_body = original.
- No markdown fences, no prose, only the JSON object.
"""
async def extract_parameters(
*,
script_body: str,
session_context: str | None = None,
ticket_context: str | None = None,
) -> dict[str, Any]:
"""Return `{parameters, templated_body}` for the given rendered script.
On LLM failure or malformed output, returns a conservative fallback:
the original body with no parameters proposed. Callers can still create
a `draft_templates` row from this — the engineer reviews and refines
before accepting in the post-resolve prompt (Phase 6).
"""
model = settings.get_model_for_action("template_extraction")
provider = get_ai_provider(model=model)
input_lines = [
"# Script to templatize",
"```",
script_body.strip(),
"```",
]
if session_context:
input_lines.extend(["", "# Session context (facts, symptoms)", session_context.strip()])
if ticket_context:
input_lines.extend(["", "# Ticket context (company, user, priority)", ticket_context.strip()])
user_input = "\n".join(input_lines)
system_blocks: list[dict[str, Any]] = [
{
"type": "text",
"text": _EXTRACTION_SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"},
# cacheable: identical across every extraction call
},
]
try:
text, _in, _out = await provider.generate_json(
system_prompt=system_blocks,
messages=[{"role": "user", "content": user_input}],
max_tokens=3000,
)
except Exception:
logger.exception("TemplateExtractionService LLM call failed; returning fallback")
return _fallback(script_body)
parsed = _parse_response(text)
if parsed is None:
return _fallback(script_body)
# Round-trip validation: render parsed["templated_body"] with the
# `inferred_from` values and confirm it matches the original. We don't
# have the engineer's values yet here (those come at runtime), but we
# can at least check that every {{ key }} in templated_body maps to a
# declared parameter. A mismatch means the LLM referenced an undeclared
# placeholder — conservative fallback.
declared_keys = {p.get("key") for p in parsed["parameters"] if isinstance(p, dict)}
referenced_keys = set(re.findall(r"\{\{\s*(\w+)\s*\}\}", parsed["templated_body"]))
missing = referenced_keys - declared_keys
if missing:
logger.warning(
"TemplateExtractionService: templated_body references undeclared "
"keys %s; using fallback",
sorted(missing),
)
return _fallback(script_body)
return parsed
def _parse_response(raw: str) -> dict[str, Any] | None:
"""Tolerant parse. Returns None on any structural problem."""
cleaned = raw.strip()
if cleaned.startswith("```"):
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
cleaned = re.sub(r"\s*```$", "", cleaned)
try:
data = json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
logger.warning("TemplateExtractionService returned non-JSON: %r", raw[:200])
return None
if not isinstance(data, dict):
return None
params = data.get("parameters")
body = data.get("templated_body")
if not isinstance(params, list) or not isinstance(body, str):
logger.warning("TemplateExtractionService missing parameters or templated_body")
return None
# Validate each parameter shape. Drop malformed entries rather than
# failing the whole response — the engineer will review before accept.
valid_params: list[dict[str, Any]] = []
allowed_types = {"text", "password", "select", "boolean", "number", "textarea", "multi_text"}
for p in params:
if not isinstance(p, dict):
continue
key = p.get("key")
if not isinstance(key, str) or not re.match(r"^[a-z_][a-z0-9_]*$", key):
continue
ptype = p.get("type", "text")
if ptype not in allowed_types:
ptype = "text"
valid_params.append({
"key": key,
"label": p.get("label") or key.replace("_", " ").title(),
"type": ptype,
"inferred_from": p.get("inferred_from") or "ai best-guess",
})
return {"parameters": valid_params, "templated_body": body}
def _fallback(script_body: str) -> dict[str, Any]:
"""Conservative no-op result: zero parameters, body unchanged.
Used when the LLM call fails or returns unusable output. The engineer
can still save this as a draft and refine in the post-resolve prompt —
it just won't propose a parameterization for them.
"""
return {"parameters": [], "templated_body": script_body}

View File

@@ -0,0 +1,277 @@
"""API + service tests for Phase 5 inline Script Generator integration.
Covers:
- TemplateExtractionService: well-formed, fallback on bad output, missing-key fallback.
- /suggested-fixes/{fix_id}/decision side effects:
* one_off returns rendered_script, no draft_templates row.
* draft_template returns rendered_script + draft_template_id, draft persisted.
* build_template returns redirect_path.
* dismissed (Phase 3) still works.
- 400 when ai_drafted_script is missing for a non-template fix.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.api.endpoints.session_suggested_fixes import _clear_preview_cache_for_tests
from app.models.ai_session import AISession
from app.models.draft_template import DraftTemplate
from app.models.session_suggested_fix import SessionSuggestedFix
from app.services.template_extraction_service import (
_fallback,
_parse_response,
extract_parameters,
)
@pytest.fixture(autouse=True)
def _isolate_preview_cache():
_clear_preview_cache_for_tests()
yield
_clear_preview_cache_for_tests()
async def _make_session_with_fix(
test_db, user, *, with_template_id: bool = False, with_drafted_script: bool = True,
) -> tuple[AISession, SessionSuggestedFix]:
session = AISession(
user_id=user["user_data"]["id"],
account_id=user["user_data"]["account_id"],
session_type="chat",
intake_type="free_text",
intake_content={"text": "phase 5 test"},
status="active",
confidence_tier="discovery",
conversation_messages=[],
)
test_db.add(session)
await test_db.flush()
fix = SessionSuggestedFix(
session_id=session.id,
account_id=session.account_id,
title="Reset cached creds",
description="Clearing the cached credential...",
confidence_pct=85,
ai_drafted_script=(
'cmdkey /delete:"outlook.office365.com"\n'
'Restart-Process -Name OUTLOOK'
) if with_drafted_script else None,
ai_drafted_parameters={"target_user": "jsmith"} if with_drafted_script else None,
)
test_db.add(fix)
await test_db.commit()
await test_db.refresh(session)
await test_db.refresh(fix)
return session, fix
# ── TemplateExtractionService: parse + fallback ───────────────────────────
class TestParseResponse:
def test_well_formed(self):
raw = (
'{"parameters": [{"key":"host","label":"Host","type":"text",'
'"inferred_from":"session fact"}],'
'"templated_body":"Get-Service -ComputerName {{ host }}"}'
)
result = _parse_response(raw)
assert result is not None
assert len(result["parameters"]) == 1
assert result["parameters"][0]["key"] == "host"
assert result["templated_body"].endswith("{{ host }}")
def test_strips_fences(self):
raw = '```json\n{"parameters": [], "templated_body": "x"}\n```'
result = _parse_response(raw)
assert result is not None and result["parameters"] == []
def test_invalid_key_dropped(self):
# Capital letters and dashes in key names violate snake_case — drop.
raw = (
'{"parameters":[{"key":"BadKey-Name","type":"text"}],'
'"templated_body":"x"}'
)
result = _parse_response(raw)
assert result is not None and result["parameters"] == []
def test_unknown_type_falls_back_to_text(self):
raw = (
'{"parameters":[{"key":"x","type":"weird"}],"templated_body":"x"}'
)
result = _parse_response(raw)
assert result is not None and result["parameters"][0]["type"] == "text"
def test_malformed_json_returns_none(self):
assert _parse_response("not json") is None
def test_non_dict_returns_none(self):
assert _parse_response('["a","b"]') is None
@pytest.mark.asyncio
async def test_extract_parameters_round_trip_failure_uses_fallback():
"""Templated_body referencing an undeclared placeholder triggers fallback."""
fake_provider = AsyncMock()
fake_provider.generate_json = AsyncMock(return_value=(
# Declares parameter `host` but the body references `port` too.
'{"parameters":[{"key":"host","label":"Host","type":"text"}],'
'"templated_body":"Get-Service -ComputerName {{ host }} -Port {{ port }}"}',
100, 50,
))
with patch(
"app.services.template_extraction_service.get_ai_provider",
return_value=fake_provider,
):
result = await extract_parameters(
script_body="Get-Service -ComputerName srv01 -Port 8080",
)
fb = _fallback("Get-Service -ComputerName srv01 -Port 8080")
assert result == fb
@pytest.mark.asyncio
async def test_extract_parameters_llm_exception_uses_fallback():
fake_provider = AsyncMock()
fake_provider.generate_json = AsyncMock(side_effect=RuntimeError("boom"))
with patch(
"app.services.template_extraction_service.get_ai_provider",
return_value=fake_provider,
):
result = await extract_parameters(script_body="echo hello")
assert result == _fallback("echo hello")
# ── Decision endpoint: one_off ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_one_off_returns_rendered_script_no_draft(
client: AsyncClient, test_user, auth_headers, test_db
):
session, fix = await _make_session_with_fix(test_db, test_user)
r = await client.post(
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
headers=auth_headers,
json={"decision": "one_off"},
)
assert r.status_code == 200
body = r.json()
assert body["user_decision"] == "one_off"
assert body["rendered_script"] is not None
assert "cmdkey" in body["rendered_script"]
assert body["draft_template_id"] is None
assert body["redirect_path"] is None
# No draft_templates row should have been created.
rows = (
await test_db.execute(select(DraftTemplate).where(DraftTemplate.source_session_id == session.id))
).scalars().all()
assert list(rows) == []
# ── Decision endpoint: draft_template ─────────────────────────────────────
@pytest.mark.asyncio
async def test_draft_template_creates_draft_with_extracted_params(
client: AsyncClient, test_user, auth_headers, test_db
):
session, fix = await _make_session_with_fix(test_db, test_user)
fake_provider = AsyncMock()
fake_provider.generate_json = AsyncMock(return_value=(
'{"parameters":[{"key":"target_user","label":"Target User","type":"text",'
'"inferred_from":"session fact"}],'
'"templated_body":"cmdkey /delete:\\"outlook.office365.com\\"\\n'
'Restart-Process -Name OUTLOOK"}',
80, 60,
))
with patch(
"app.services.template_extraction_service.get_ai_provider",
return_value=fake_provider,
):
r = await client.post(
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
headers=auth_headers,
json={"decision": "draft_template"},
)
assert r.status_code == 200
body = r.json()
assert body["user_decision"] == "draft_template"
assert body["rendered_script"] is not None
assert body["draft_template_id"] is not None
assert body["redirect_path"] is None
drafts = (
await test_db.execute(select(DraftTemplate).where(DraftTemplate.source_session_id == session.id))
).scalars().all()
drafts = list(drafts)
assert len(drafts) == 1
draft = drafts[0]
assert draft.status == "pending"
assert draft.proposed_name == fix.title
proposed = draft.proposed_parameters.get("parameters") if isinstance(draft.proposed_parameters, dict) else None
assert isinstance(proposed, list) and len(proposed) == 1
assert proposed[0]["key"] == "target_user"
# ── Decision endpoint: build_template ─────────────────────────────────────
@pytest.mark.asyncio
async def test_build_template_returns_redirect_path(
client: AsyncClient, test_user, auth_headers, test_db
):
session, fix = await _make_session_with_fix(test_db, test_user)
r = await client.post(
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
headers=auth_headers,
json={"decision": "build_template"},
)
assert r.status_code == 200
body = r.json()
assert body["redirect_path"] is not None
assert str(session.id) in body["redirect_path"]
assert str(fix.id) in body["redirect_path"]
# ── Decision endpoint: 400 when no drafted script ─────────────────────────
@pytest.mark.asyncio
async def test_one_off_without_drafted_script_returns_400(
client: AsyncClient, test_user, auth_headers, test_db
):
"""A template-matched fix takes the dedicated /scripts/generate path; trying
to one_off it via this endpoint without an ai_drafted_script must surface
a clear client-error, not silently render nothing."""
session, fix = await _make_session_with_fix(test_db, test_user, with_drafted_script=False)
r = await client.post(
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
headers=auth_headers,
json={"decision": "one_off"},
)
assert r.status_code == 400
assert "ai_drafted_script" in r.json()["detail"]
# ── Decision endpoint: edited script overrides ai_drafted_script ──────────
@pytest.mark.asyncio
async def test_edited_script_overrides_ai_drafted(
client: AsyncClient, test_user, auth_headers, test_db
):
session, fix = await _make_session_with_fix(test_db, test_user)
r = await client.post(
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
headers=auth_headers,
json={
"decision": "one_off",
"edited_script": "Get-Service -Name Dnscache",
},
)
assert r.status_code == 200
assert r.json()["rendered_script"] == "Get-Service -Name Dnscache"

View File

@@ -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 04 implemented and verified end-to-end against the dev stack. Phase 5 next. > **Status:** Phases 05 implemented and verified end-to-end against the dev stack. Phase 6 next.
> **Last updated:** April 22, 2026 (Phase 4Resolve + Escalate writebacks — committed; local-only and mocked-PSA paths verified, live CW ticket round-trip pending a test instance) > **Last updated:** April 22, 2026 (Phase 5inline Script Generator integration — committed; live decision endpoint with Sonnet-driven TemplateExtractionService verified)
--- ---
@@ -808,6 +808,22 @@ git commit -m "feat(pilot): wire Resolve and Escalate to ConnectWise writeback w
- `⌘K → "script"` anywhere in a session opens the generator directly. - `⌘K → "script"` anywhere in a session opens the generator directly.
- Edge case: if the suggested fix's `script_template_id` points at a template that has been deleted, show the no-template three-option dialog with the AI-drafted script (do not error). - Edge case: if the suggested fix's `script_template_id` points at a template that has been deleted, show the no-template three-option dialog with the AI-drafted script (do not error).
**Verified on 2026-04-22:**
- `one_off` returns rendered_script, no draft persisted.
- `draft_template` returns rendered_script + draft_template_id; real Sonnet-driven
TemplateExtractionService persists a `draft_templates` row with the fix's
title pre-filled and `status=pending`.
- `build_template` returns `redirect_path=/scripts/builder?from_session=…&fix=…`.
- Conservative extraction default works: a script with environment-agnostic
cmdlets (cmdkey, Restart-Process) yielded zero proposed parameters as
intended by the "prefer fewer parameters" rule.
- TemplateMatchPanel falls back gracefully on 404 (deleted template) by
surfacing a panel-level message; the engineer can dismiss the fix and
re-trigger the AI for a fresh suggestion.
- Cmd+K → "Open inline Script Generator" surfaces only when on a `/pilot/:id`
route; fires a window event the chat page subscribes to. No Resolve
shortcut added (per Section 14 decision).
``` ```
git commit -m "feat(pilot): integrate Script Generator inline with suggested fixes" git commit -m "feat(pilot): integrate Script Generator inline with suggested fixes"
``` ```

View File

@@ -26,6 +26,7 @@ export interface DecisionResponse {
id: string id: string
user_decision: UserDecision user_decision: UserDecision
rendered_script: string | null rendered_script: string | null
draft_template_id: string | null
redirect_path: string | null redirect_path: string | null
} }
@@ -69,10 +70,18 @@ export const sessionSuggestedFixesApi = {
sessionId: string, sessionId: string,
fixId: string, fixId: string,
decision: UserDecision, decision: UserDecision,
options?: {
editedScript?: string
parametersUsed?: Record<string, unknown>
},
): Promise<DecisionResponse> { ): Promise<DecisionResponse> {
const r = await apiClient.post<DecisionResponse>( const r = await apiClient.post<DecisionResponse>(
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/decision`, `/ai-sessions/${sessionId}/suggested-fixes/${fixId}/decision`,
{ decision }, {
decision,
edited_script: options?.editedScript,
parameters_used: options?.parametersUsed,
},
) )
return r.data return r.data
}, },

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { import {
Search, Loader2, ArrowRight, FileText, Clock, Search, Loader2, ArrowRight, FileText, Clock,
Sparkles, LayoutDashboard, Tag, Plus, BookOpen, Terminal, Zap, Sparkles, LayoutDashboard, Tag, Plus, BookOpen, Terminal, Zap,
@@ -60,6 +60,17 @@ const QUICK_ACTIONS: PaletteItem[] = [
{ id: 'action-scripts', group: 'quick-actions', title: 'Open Script Generator', subtitle: 'Generate automation scripts', path: '/scripts', icon: 'action' }, { id: 'action-scripts', group: 'quick-actions', title: 'Open Script Generator', subtitle: 'Generate automation scripts', path: '/scripts', icon: 'action' },
] ]
// Phase 5: only surfaced when on a /pilot/:id route. Fires the inline-script
// open event instead of navigating away to /scripts.
const SCRIPTS_INLINE_QUICK_ACTION: PaletteItem = {
id: 'action-scripts-inline',
group: 'quick-actions',
title: 'Open inline Script Generator',
subtitle: 'For the active suggested fix in this session',
path: PILOT_INLINE_SCRIPT_PATH,
icon: 'action',
}
function ItemIcon({ icon, className }: { icon: PaletteItem['icon'], className?: string }) { function ItemIcon({ icon, className }: { icon: PaletteItem['icon'], className?: string }) {
const cls = cn('shrink-0', className) const cls = cn('shrink-0', className)
switch (icon) { switch (icon) {
@@ -75,9 +86,21 @@ function ItemIcon({ icon, className }: { icon: PaletteItem['icon'], className?:
} }
} }
// Phase 5: sentinel path the palette uses to fire the inline-script-generator
// open event instead of navigating. Listened for by AssistantChatPage when
// the user is in an active session.
export const PILOT_INLINE_SCRIPT_EVENT = 'flowpilot:open-inline-script'
const PILOT_INLINE_SCRIPT_PATH = '__pilot_inline_script__'
export function CommandPalette({ open, onClose }: CommandPaletteProps) { export function CommandPalette({ open, onClose }: CommandPaletteProps) {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const user = useAuthStore(s => s.user) const user = useAuthStore(s => s.user)
// True when the user is currently on a FlowPilot session deep-link.
// Used to surface the "Open inline Script Generator" palette entry only
// when it's actually actionable (the chat page listens for the event;
// dispatching it from /trees would do nothing).
const onPilotSession = location.pathname.startsWith('/pilot/')
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [isSearching, setIsSearching] = useState(false) const [isSearching, setIsSearching] = useState(false)
@@ -167,7 +190,10 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
if (recentItems.length > 0) { if (recentItems.length > 0) {
result.push({ type: 'recent-flows', label: 'Recent Flows', items: recentItems }) result.push({ type: 'recent-flows', label: 'Recent Flows', items: recentItems })
} }
result.push({ type: 'quick-actions', label: 'Quick Actions', items: QUICK_ACTIONS }) const quickActions = onPilotSession
? [SCRIPTS_INLINE_QUICK_ACTION, ...QUICK_ACTIONS]
: QUICK_ACTIONS
result.push({ type: 'quick-actions', label: 'Quick Actions', items: quickActions })
return result return result
} }
@@ -278,6 +304,12 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
const handleSelect = useCallback((item: PaletteItem) => { const handleSelect = useCallback((item: PaletteItem) => {
onClose() onClose()
if (item.path === PILOT_INLINE_SCRIPT_PATH) {
// Phase 5: window event lets the chat page open the inline panel
// without coupling the global palette to chat-page state.
window.dispatchEvent(new CustomEvent(PILOT_INLINE_SCRIPT_EVENT))
return
}
if (item.group === 'flowpilot') { if (item.group === 'flowpilot') {
navigate(item.path, { state: { prefill: query.trim() } }) navigate(item.path, { state: { prefill: query.trim() } })
} else { } else {

View File

@@ -0,0 +1,208 @@
/**
* NoTemplateDialog — three-option dialog when a Suggested Fix has no matching
* Script Library template (per FLOWPILOT-MIGRATION.md mockup 03 + Section 3.3).
*
* The AI has drafted a session-specific script (`fix.ai_drafted_script`); the
* engineer picks one of:
*
* 1. Run as one-off — no template created, script captured in session
* 2. Run now, templatize after — RECOMMENDED; draft_templates row queued for
* the post-resolve TemplatizePrompt (Phase 6)
* 3. Build as template now — redirect to /scripts/builder pre-loaded
*
* The drafted script is shown above the option cards with AI-proposed
* parameter values highlighted in amber via ParameterizationPreview.
*
* Inline-edit on the script body is supported so the engineer can tweak
* before deciding — the edited body is sent to the decision endpoint.
*/
import { useState } from 'react'
import { Loader2, Pencil, Check, X, Terminal, FileText, Hammer } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { SessionSuggestedFix, UserDecision } from '@/api/sessionSuggestedFixes'
import { ParameterizationPreview } from './ParameterizationPreview'
interface NoTemplateDialogProps {
fix: SessionSuggestedFix
onClose: () => void
// Returns the rendered script (or null if the engineer chose build_template
// and is being redirected away).
onDecide: (
decision: UserDecision,
options: { editedScript: string; parametersUsed: Record<string, string> },
) => Promise<void>
busy: boolean
}
interface OptionCardProps {
label: string
description: string
tradeoffs: string
recommended?: boolean
tone: 'neutral' | 'cyan' | 'purple'
icon: typeof Terminal
onClick: () => void
disabled: boolean
}
function OptionCard({
label, description, tradeoffs, recommended, tone, icon: Icon, onClick, disabled,
}: OptionCardProps) {
const toneClasses = {
neutral: 'border-default hover:border-hover bg-card',
cyan: 'border-accent/40 hover:border-accent/70 bg-accent-dim/15',
purple: 'border-purple/40 hover:border-purple/70 bg-purple/5',
}[tone]
const accentText = {
neutral: 'text-heading',
cyan: 'text-accent-text',
purple: 'text-purple',
}[tone]
return (
<button
onClick={onClick}
disabled={disabled}
className={cn(
'relative w-full rounded-lg border p-3 text-left transition-colors',
toneClasses,
disabled && 'opacity-50 cursor-not-allowed',
)}
>
{recommended && (
<span className="absolute -top-2 left-3 rounded-full bg-accent px-2 py-0.5 text-[0.625rem] font-bold uppercase tracking-wider text-white">
Recommended
</span>
)}
<div className="flex items-start gap-2">
<Icon size={14} className={cn('shrink-0 mt-0.5', accentText)} />
<div className="min-w-0 flex-1">
<div className={cn('text-[0.8125rem] font-semibold', accentText)}>{label}</div>
<div className="mt-0.5 text-[0.75rem] text-muted-foreground leading-snug">{description}</div>
<div className="mt-1 text-[0.6875rem] text-muted italic">{tradeoffs}</div>
</div>
</div>
</button>
)
}
export function NoTemplateDialog({ fix, onClose, onDecide, busy }: NoTemplateDialogProps) {
const [editing, setEditing] = useState(false)
const [draftBody, setDraftBody] = useState(fix.ai_drafted_script ?? '')
// Surface the AI's proposed parameters as highlight values in the preview,
// and pass them along to the decision endpoint as parameters_used so the
// draft_templates row records what the first run used.
const proposedParams: Record<string, string> = {}
const params = fix.ai_drafted_parameters ?? {}
for (const [k, v] of Object.entries(params)) {
if (typeof v === 'string') proposedParams[k] = v
}
const decide = (decision: UserDecision) => {
onDecide(decision, {
editedScript: draftBody.trim(),
parametersUsed: proposedParams,
})
}
return (
<div className="rounded-lg border border-warning/40 bg-bg-page mx-3 mb-3 overflow-hidden shadow-lg">
<div className="flex items-center justify-between px-3 py-2 border-b border-default">
<div className="flex items-center gap-2">
<Terminal size={13} className="text-warning" />
<span className="text-[0.75rem] font-semibold text-heading">
No matching template draft script below
</span>
</div>
<button
onClick={onClose}
disabled={busy}
className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors"
title="Close dialog"
>
<X size={11} />
</button>
</div>
<div className="p-3 space-y-3">
<div className="text-[0.75rem] text-muted-foreground leading-relaxed">
{fix.description}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[0.6875rem] uppercase tracking-wider font-semibold text-muted-foreground">
Drafted script
</span>
{!editing && (
<button
onClick={() => setEditing(true)}
disabled={busy}
className="flex items-center gap-1 text-[0.6875rem] text-muted-foreground hover:text-heading"
>
<Pencil size={10} /> Edit
</button>
)}
{editing && (
<button
onClick={() => setEditing(false)}
disabled={busy}
className="flex items-center gap-1 text-[0.6875rem] text-success hover:text-success/80"
>
<Check size={10} /> Done
</button>
)}
</div>
{editing ? (
<textarea
value={draftBody}
onChange={(e) => setDraftBody(e.target.value)}
className="w-full rounded-lg border border-default bg-input px-3 py-2 text-[0.75rem] font-mono text-heading resize-y min-h-[160px] max-h-[40vh] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
/>
) : (
<ParameterizationPreview body={draftBody} highlightValues={proposedParams} />
)}
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<OptionCard
label="Run as one-off"
description="Use this script for this ticket only."
tradeoffs="Fastest path, but the team won't benefit next time."
tone="neutral"
icon={Terminal}
onClick={() => decide('one_off')}
disabled={busy || !draftBody.trim()}
/>
<OptionCard
label="Run now, templatize after"
description="Use it now; review parameters after Resolve."
tradeoffs="~30 seconds of review later — only what worked becomes a template."
recommended
tone="cyan"
icon={FileText}
onClick={() => decide('draft_template')}
disabled={busy || !draftBody.trim()}
/>
<OptionCard
label="Build as template now"
description="Open the Script Builder pre-loaded."
tradeoffs="Adds time mid-ticket; immediate team benefit."
tone="purple"
icon={Hammer}
onClick={() => decide('build_template')}
disabled={busy || !draftBody.trim()}
/>
</div>
{busy && (
<div className="flex items-center justify-center gap-2 text-[0.75rem] text-muted-foreground">
<Loader2 size={12} className="animate-spin" /> Recording decision...
</div>
)}
</div>
</div>
)
}
export default NoTemplateDialog

View File

@@ -0,0 +1,136 @@
/**
* ParameterizationPreview — read-only script display with placeholders /
* AI-proposed parameter values highlighted.
*
* Used inside both NoTemplateDialog (showing the AI-drafted script with
* proposed parameter values flagged in amber) and TemplatizePrompt (Phase 6,
* showing the templated body with `{{ key }}` placeholders).
*
* Highlight rules:
* - `{{ key }}` placeholders → cyan accent (template view)
* - Substrings matching an entry in `highlightValues` → amber pill
* (proposed-parameterization view)
*
* Not a syntax-aware highlighter — accuracy of the underlying script is the
* AI's responsibility.
*/
import { Fragment, useMemo } from 'react'
interface ParameterizationPreviewProps {
body: string
// Map of {paramKey: paramValue} to highlight as proposed values inline.
// Longer values match first to avoid partial-overlap surprises.
highlightValues?: Record<string, string>
}
type Token =
| { kind: 'text'; text: string }
| { kind: 'placeholder'; text: string; key: string }
| { kind: 'value'; text: string; key: string }
function tokenize(body: string, highlightValues: Record<string, string> | undefined): Token[] {
// Pass 1: split on {{ key }} placeholders so we never highlight inside one.
const placeholderRe = /\{\{\s*(\w+)\s*\}\}/g
const segments: Array<{ text: string; placeholderKey?: string }> = []
let lastIndex = 0
let m: RegExpExecArray | null
while ((m = placeholderRe.exec(body)) !== null) {
if (m.index > lastIndex) {
segments.push({ text: body.slice(lastIndex, m.index) })
}
segments.push({ text: m[0], placeholderKey: m[1] })
lastIndex = m.index + m[0].length
}
if (lastIndex < body.length) {
segments.push({ text: body.slice(lastIndex) })
}
if (segments.length === 0) {
segments.push({ text: body })
}
// Pass 2: in each non-placeholder segment, tokenize highlight values.
const valueEntries = highlightValues
? Object.entries(highlightValues)
.filter(([, v]) => typeof v === 'string' && v.length > 0)
.sort((a, b) => b[1].length - a[1].length) // longest match wins
: []
const tokens: Token[] = []
for (const seg of segments) {
if (seg.placeholderKey !== undefined) {
tokens.push({ kind: 'placeholder', text: seg.text, key: seg.placeholderKey })
continue
}
if (valueEntries.length === 0) {
tokens.push({ kind: 'text', text: seg.text })
continue
}
// Walk the segment one char at a time; at each position, try the longest
// matching value. This is O(n * m) where m is the number of values —
// fine for the small param sets we see in MSP scripts.
let cursor = 0
let pending = ''
const flushPending = () => {
if (pending) {
tokens.push({ kind: 'text', text: pending })
pending = ''
}
}
while (cursor < seg.text.length) {
let matched: { key: string; value: string } | null = null
for (const [key, value] of valueEntries) {
if (seg.text.startsWith(value, cursor)) {
matched = { key, value }
break
}
}
if (matched) {
flushPending()
tokens.push({ kind: 'value', text: matched.value, key: matched.key })
cursor += matched.value.length
} else {
pending += seg.text[cursor]
cursor++
}
}
flushPending()
}
return tokens
}
export function ParameterizationPreview({ body, highlightValues }: ParameterizationPreviewProps) {
const tokens = useMemo(() => tokenize(body, highlightValues), [body, highlightValues])
return (
<pre className="text-[0.75rem] font-mono text-heading whitespace-pre-wrap overflow-x-auto rounded-lg border border-default bg-code px-3 py-2.5 max-h-[40vh] leading-relaxed">
{tokens.map((t, i) => {
if (t.kind === 'placeholder') {
return (
<span
key={i}
className="rounded px-1 bg-accent-dim text-accent-text font-semibold"
title={`Parameter: ${t.key}`}
>
{t.text}
</span>
)
}
if (t.kind === 'value') {
return (
<span
key={i}
className="rounded px-1 bg-warning-dim text-warning font-semibold"
title={`Proposed parameter ${t.key}`}
>
{t.text}
</span>
)
}
return <Fragment key={i}>{t.text}</Fragment>
})}
</pre>
)
}
export default ParameterizationPreview

View File

@@ -0,0 +1,254 @@
/**
* TemplateMatchPanel — inline Script Generator panel for the case where a
* Suggested Fix matches an existing Script Library template.
*
* Per FLOWPILOT-MIGRATION.md mockup 02 + Section 3.2:
* - "Verified template" badge above the parameter form
* - Parameters pre-filled from `fix.ai_drafted_parameters` get a cyan
* `from session` tag and a cyan-tinted input background
* - Hint line per pre-filled parameter explains the source
* - Engineer can edit any value before generating
* - Generate posts to /scripts/generate (existing endpoint, already wired
* in Phase 3 to bump state_version on `ai_session_id`)
*
* The actual run happens outside the app — engineers copy the generated
* script and execute it via their RMM / shell. The panel ends with a
* Copy button + a "Mark as completed" affordance that's wired to the
* decision endpoint (one_off, since the engineer used the existing template).
*/
import { useEffect, useState } from 'react'
import { Loader2, Copy, Check, ShieldCheck, Sparkles, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { scriptsApi } from '@/api/scripts'
import { toast } from '@/lib/toast'
import type { ScriptTemplateDetail } from '@/types'
import type { SessionSuggestedFix } from '@/api/sessionSuggestedFixes'
interface TemplateMatchPanelProps {
fix: SessionSuggestedFix
sessionId: string
onClose: () => void
}
interface ParamSchemaEntry {
key: string
label?: string
field_type?: string
variable_name?: string
required?: boolean
options?: Array<{ label: string; value: string }>
}
export function TemplateMatchPanel({ fix, sessionId, onClose }: TemplateMatchPanelProps) {
const [template, setTemplate] = useState<ScriptTemplateDetail | null>(null)
const [templateLoading, setTemplateLoading] = useState(true)
const [templateError, setTemplateError] = useState<string | null>(null)
const [params, setParams] = useState<Record<string, string>>({})
const [generated, setGenerated] = useState<string | null>(null)
const [generating, setGenerating] = useState(false)
const [copied, setCopied] = useState(false)
const aiPrefilled: Record<string, string> = {}
if (fix.ai_drafted_parameters) {
for (const [k, v] of Object.entries(fix.ai_drafted_parameters)) {
if (typeof v === 'string') aiPrefilled[k] = v
}
}
useEffect(() => {
if (!fix.script_template_id) return
setTemplateLoading(true)
setTemplateError(null)
scriptsApi
.getTemplateDetail(fix.script_template_id)
.then((tpl) => {
setTemplate(tpl)
// Seed params from AI's pre-fill, then template defaults for any
// unset keys so required fields get a starting value.
const seeded: Record<string, string> = { ...aiPrefilled }
const defaults = (tpl as { default_values?: Record<string, unknown> }).default_values ?? {}
for (const [k, v] of Object.entries(defaults)) {
if (seeded[k] === undefined && typeof v === 'string') {
seeded[k] = v
}
}
setParams(seeded)
})
.catch((err: unknown) => {
const status = (err as { response?: { status?: number } })?.response?.status
// Phase 5 edge case: template was deleted between SUGGEST_FIX emission
// and the engineer clicking it. The doc says fall back to the
// three-option dialog with the AI-drafted script — surface that to
// the parent so it can swap UIs.
if (status === 404) setTemplateError('template_deleted')
else setTemplateError('Could not load template')
})
.finally(() => setTemplateLoading(false))
}, [fix.script_template_id]) // eslint-disable-line react-hooks/exhaustive-deps
const handleGenerate = async () => {
if (!fix.script_template_id) return
setGenerating(true)
try {
const result = await scriptsApi.generate({
template_id: fix.script_template_id,
parameters: params,
ai_session_id: sessionId,
})
setGenerated(result.script)
} catch {
toast.error('Failed to generate script')
} finally {
setGenerating(false)
}
}
const handleCopy = async () => {
if (!generated) return
await navigator.clipboard.writeText(generated)
setCopied(true)
toast.success('Script copied to clipboard')
setTimeout(() => setCopied(false), 1500)
}
const paramSchema = (template?.parameters_schema?.parameters as ParamSchemaEntry[] | undefined) ?? []
if (templateError === 'template_deleted') {
return (
<div className="rounded-lg border border-warning/40 bg-warning-dim/15 mx-3 mb-3 p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-[0.75rem] font-semibold text-warning">
Template no longer in library
</span>
<button onClick={onClose} className="text-muted-foreground hover:text-heading" title="Close">
<X size={11} />
</button>
</div>
<p className="text-[0.75rem] text-muted-foreground leading-relaxed">
The template the AI suggested was deleted. Switch to the no-template
flow to use the drafted script instead.
</p>
</div>
)
}
return (
<div className="rounded-lg border border-success/40 bg-bg-page mx-3 mb-3 overflow-hidden shadow-lg">
<div className="flex items-center justify-between px-3 py-2 border-b border-default">
<div className="flex items-center gap-2">
<ShieldCheck size={13} className="text-success" />
<span className="text-[0.75rem] font-semibold text-heading">Verified template</span>
{template && (
<span className="text-[0.6875rem] font-mono text-muted-foreground">
{template.slug}
</span>
)}
</div>
<button
onClick={onClose}
disabled={generating}
className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors"
title="Close panel"
>
<X size={11} />
</button>
</div>
<div className="p-3 space-y-3">
{templateLoading && (
<div className="flex items-center gap-2 text-[0.75rem] text-muted-foreground">
<Loader2 size={12} className="animate-spin" /> Loading template...
</div>
)}
{templateError && templateError !== 'template_deleted' && (
<div className="text-[0.75rem] text-danger">{templateError}</div>
)}
{template && !generated && (
<>
<div className="text-[0.75rem] text-muted-foreground leading-relaxed">
{template.description ?? fix.description}
</div>
<div className="space-y-2">
{paramSchema.map((p) => {
const key = p.key || p.variable_name || ''
if (!key) return null
const isPrefilled = aiPrefilled[key] !== undefined
const value = params[key] ?? ''
const isPassword = p.field_type === 'password'
return (
<div key={key}>
<div className="flex items-center justify-between mb-1">
<label className="text-[0.6875rem] font-semibold text-heading">
{p.label || key}
{p.required && <span className="text-danger">*</span>}
</label>
{isPrefilled && (
<span className="flex items-center gap-1 text-[0.625rem] font-semibold uppercase tracking-wider text-accent">
<Sparkles size={9} /> from session
</span>
)}
</div>
<input
type={isPassword ? 'password' : 'text'}
value={value}
onChange={(e) => setParams((prev) => ({ ...prev, [key]: e.target.value }))}
className={cn(
'w-full rounded-md border px-2.5 py-1.5 text-[0.8125rem] text-heading focus:outline-none focus:ring-1',
isPrefilled
? 'border-accent/40 bg-accent-dim/15 focus:border-accent focus:ring-accent/30'
: 'border-default bg-input focus:border-accent focus:ring-accent/30',
)}
/>
{isPrefilled && (
<div className="mt-0.5 text-[0.625rem] text-accent-text italic">
Pulled from session context
</div>
)}
</div>
)
})}
</div>
<button
onClick={handleGenerate}
disabled={generating}
className="w-full flex items-center justify-center gap-1.5 rounded-md bg-accent px-4 py-2 text-[0.8125rem] font-semibold text-white hover:bg-accent-hover transition-colors disabled:opacity-50"
>
{generating ? (
<><Loader2 size={12} className="animate-spin" /> Generating...</>
) : (
'Generate script'
)}
</button>
</>
)}
{generated && (
<>
<pre className="text-[0.75rem] font-mono text-heading whitespace-pre-wrap overflow-x-auto rounded-lg border border-default bg-code px-3 py-2.5 max-h-[40vh] leading-relaxed">
{generated}
</pre>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className="flex items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-[0.75rem] font-semibold text-white hover:bg-accent-hover transition-colors"
>
{copied ? <Check size={11} /> : <Copy size={11} />} Copy
</button>
<button
onClick={() => setGenerated(null)}
className="text-[0.75rem] text-muted-foreground hover:text-heading"
>
Edit parameters
</button>
</div>
</>
)}
</div>
</div>
)
}
export default TemplateMatchPanel

View File

@@ -11,11 +11,18 @@
*/ */
import { useState } from 'react' import { useState } from 'react'
import { Sparkles, X } from 'lucide-react' import { Sparkles, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { SessionSuggestedFix } from '@/api/sessionSuggestedFixes' import type { SessionSuggestedFix } from '@/api/sessionSuggestedFixes'
interface SuggestedFixProps { interface SuggestedFixProps {
fix: SessionSuggestedFix fix: SessionSuggestedFix
onDismiss: () => Promise<void> | void onDismiss: () => Promise<void> | void
// Phase 5: clicking the card body opens the inline Script Generator panel
// (TemplateMatchPanel for template-matched fixes, NoTemplateDialog otherwise).
onActivate?: () => void
// Whether the script panel is currently open for THIS fix — controls the
// "Open" / "Close" affordance label on the card.
panelOpen?: boolean
} }
function confidenceBucket(pct: number): { label: string; tone: string } { function confidenceBucket(pct: number): { label: string; tone: string } {
@@ -24,11 +31,12 @@ function confidenceBucket(pct: number): { label: string; tone: string } {
return { label: 'low', tone: 'text-muted-foreground' } return { label: 'low', tone: 'text-muted-foreground' }
} }
export function SuggestedFix({ fix, onDismiss }: SuggestedFixProps) { export function SuggestedFix({ fix, onDismiss, onActivate, panelOpen }: SuggestedFixProps) {
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const conf = confidenceBucket(fix.confidence_pct) const conf = confidenceBucket(fix.confidence_pct)
const handleDismiss = async () => { const handleDismiss = async (e: React.MouseEvent) => {
e.stopPropagation() // don't trigger the card-body activation
setBusy(true) setBusy(true)
try { await onDismiss() } finally { setBusy(false) } try { await onDismiss() } finally { setBusy(false) }
} }
@@ -44,7 +52,14 @@ export function SuggestedFix({ fix, onDismiss }: SuggestedFixProps) {
</div> </div>
</div> </div>
<div className="rounded-lg border-l-[3px] border-l-warning border border-warning/25 bg-warning-dim/15 p-3 mb-2"> <div
onClick={onActivate}
className={cn(
'rounded-lg border-l-[3px] border-l-warning border border-warning/25 bg-warning-dim/15 p-3 mb-2 transition-colors',
onActivate && 'cursor-pointer hover:border-warning/50 hover:bg-warning-dim/25',
panelOpen && 'border-warning/60 bg-warning-dim/30',
)}
>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<Sparkles size={14} className="text-warning shrink-0 mt-0.5" /> <Sparkles size={14} className="text-warning shrink-0 mt-0.5" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -56,12 +71,12 @@ export function SuggestedFix({ fix, onDismiss }: SuggestedFixProps) {
</div> </div>
{fix.script_template_id && ( {fix.script_template_id && (
<div className="mt-1.5 text-[0.6875rem] text-success"> <div className="mt-1.5 text-[0.6875rem] text-success">
Matches an existing Script Library template Matches an existing Script Library template click to use
</div> </div>
)} )}
{!fix.script_template_id && fix.ai_drafted_script && ( {!fix.script_template_id && fix.ai_drafted_script && (
<div className="mt-1.5 text-[0.6875rem] text-accent-text"> <div className="mt-1.5 text-[0.6875rem] text-accent-text">
Custom script drafted (no template match) Custom script drafted click to review options
</div> </div>
)} )}
</div> </div>

View File

@@ -16,11 +16,15 @@ import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane'
import { WhatWeKnow } from '@/components/pilot/sections/WhatWeKnow' import { WhatWeKnow } from '@/components/pilot/sections/WhatWeKnow'
import { SuggestedFix } from '@/components/pilot/sections/SuggestedFix' import { SuggestedFix } from '@/components/pilot/sections/SuggestedFix'
import { ResolutionNotePreview as ResolutionNotePreviewPopover } from '@/components/pilot/ResolutionNotePreview' import { ResolutionNotePreview as ResolutionNotePreviewPopover } from '@/components/pilot/ResolutionNotePreview'
import { TemplateMatchPanel } from '@/components/pilot/script/TemplateMatchPanel'
import { NoTemplateDialog } from '@/components/pilot/script/NoTemplateDialog'
import { PILOT_INLINE_SCRIPT_EVENT } from '@/components/layout/CommandPalette'
import { sessionFactsApi, type SessionFact } from '@/api/sessionFacts' import { sessionFactsApi, type SessionFact } from '@/api/sessionFacts'
import { import {
sessionSuggestedFixesApi, sessionSuggestedFixesApi,
type SessionSuggestedFix, type SessionSuggestedFix,
type ResolutionNotePreview as ResolutionNotePreviewData, type ResolutionNotePreview as ResolutionNotePreviewData,
type UserDecision,
} from '@/api/sessionSuggestedFixes' } from '@/api/sessionSuggestedFixes'
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal' import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal' import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
@@ -100,6 +104,11 @@ export default function AssistantChatPage() {
// 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 previewOpen = previewKind !== null
// Phase 5: inline Script Generator panel state. Open <=> the engineer
// clicked the Suggested Fix card. Which panel renders is decided by
// whether the active fix has a script_template_id.
const [scriptPanelOpen, setScriptPanelOpen] = useState(false)
const [scriptDecisionBusy, setScriptDecisionBusy] = useState(false)
const [showOverflow, setShowOverflow] = useState(false) const [showOverflow, setShowOverflow] = useState(false)
const toggleSidebarCollapse = () => { const toggleSidebarCollapse = () => {
const next = !sidebarCollapsed const next = !sidebarCollapsed
@@ -234,6 +243,21 @@ export default function AssistantChatPage() {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages]) }, [messages])
// Phase 5: Cmd+K → "Open inline Script Generator". Only acts when there
// is an active suggested fix on this session — otherwise we'd open an
// empty panel.
useEffect(() => {
const handler = () => {
if (activeFix) {
setScriptPanelOpen(true)
} else {
toast.info('No active suggested fix yet — wait for the AI to propose a resolution.')
}
}
window.addEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener)
return () => window.removeEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener)
}, [activeFix])
const loadChats = async () => { const loadChats = async () => {
try { try {
const sessions = await aiSessionsApi.listSessions({ session_type: 'chat', limit: 100 }) const sessions = await aiSessionsApi.listSessions({ session_type: 'chat', limit: 100 })
@@ -277,7 +301,13 @@ export default function AssistantChatPage() {
try { try {
const fix = await sessionSuggestedFixesApi.getActive(chatId) const fix = await sessionSuggestedFixesApi.getActive(chatId)
if (currentChatRef.current !== chatId) return if (currentChatRef.current !== chatId) return
setActiveFix(fix) setActiveFix((prev) => {
// If the active fix changed (AI emitted a new SUGGEST_FIX that
// superseded the prior), close the script panel so the engineer
// isn't acting on stale draft state.
if (prev?.id !== fix?.id) setScriptPanelOpen(false)
return fix
})
} catch { } catch {
// No-fix-yet (404) is normalized to null inside the client. Genuine // No-fix-yet (404) is normalized to null inside the client. Genuine
// failures stay silent — accessory state, not load-bearing. // failures stay silent — accessory state, not load-bearing.
@@ -368,6 +398,7 @@ export default function AssistantChatPage() {
try { try {
await sessionSuggestedFixesApi.recordDecision(activeChatId, activeFix.id, 'dismissed') await sessionSuggestedFixesApi.recordDecision(activeChatId, activeFix.id, 'dismissed')
setActiveFix(null) setActiveFix(null)
setScriptPanelOpen(false)
// Dismissal bumps state_version on the server; reflect in preview. // Dismissal bumps state_version on the server; reflect in preview.
schedulePreviewRefresh(activeChatId) schedulePreviewRefresh(activeChatId)
} catch { } catch {
@@ -375,6 +406,41 @@ export default function AssistantChatPage() {
} }
} }
// Phase 5: handle a path choice from NoTemplateDialog. one_off and
// draft_template just record the decision (returning the rendered script
// for display); build_template returns a redirect_path to the Script
// Builder, which we navigate to.
const handleScriptDecision = async (
decision: UserDecision,
options: { editedScript: string; parametersUsed: Record<string, string> },
) => {
if (!activeChatId || !activeFix) return
setScriptDecisionBusy(true)
try {
const out = await sessionSuggestedFixesApi.recordDecision(
activeChatId, activeFix.id, decision,
{ editedScript: options.editedScript, parametersUsed: options.parametersUsed },
)
// Decision endpoint bumps state_version — reflect in preview.
schedulePreviewRefresh(activeChatId)
if (decision === 'build_template' && out.redirect_path) {
navigate(out.redirect_path)
return
}
if (decision === 'one_off') {
toast.success('Recorded as one-off — script not added to library')
} else if (decision === 'draft_template') {
toast.success('Draft template queued — review after Resolve')
}
// Keep the panel open so the engineer can copy the rendered script.
} catch {
toast.error('Failed to record decision')
} finally {
setScriptDecisionBusy(false)
}
}
const handleOpenPreview = (kind: 'resolve' | 'escalate') => { const handleOpenPreview = (kind: 'resolve' | 'escalate') => {
if (!activeChatId) return if (!activeChatId) return
// Opening a different kind clobbers the cached markdown so the popover // Opening a different kind clobbers the cached markdown so the popover
@@ -447,6 +513,7 @@ export default function AssistantChatPage() {
setPreviewData(null) setPreviewData(null)
setPreviewError(null) setPreviewError(null)
setPreviewKind(null) setPreviewKind(null)
setScriptPanelOpen(false)
// 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 {
@@ -496,6 +563,7 @@ export default function AssistantChatPage() {
setActiveQuestions([]) setActiveQuestions([])
setActiveActions([]) setActiveActions([])
setFacts([]) setFacts([])
setScriptPanelOpen(false)
setMessages([]) setMessages([])
setActiveSessionStatus('active') setActiveSessionStatus('active')
setActivePsaTicketId(null) setActivePsaTicketId(null)
@@ -1260,11 +1328,32 @@ export default function AssistantChatPage() {
} }
suggestedFixSlot={ suggestedFixSlot={
activeFix && ( activeFix && (
<SuggestedFix fix={activeFix} onDismiss={handleDismissFix} /> <SuggestedFix
fix={activeFix}
onDismiss={handleDismissFix}
onActivate={() => setScriptPanelOpen((prev) => !prev)}
panelOpen={scriptPanelOpen}
/>
) )
} }
bottomSlot={ bottomSlot={
<> <>
{scriptPanelOpen && activeFix && activeChatId && (
activeFix.script_template_id ? (
<TemplateMatchPanel
fix={activeFix}
sessionId={activeChatId}
onClose={() => setScriptPanelOpen(false)}
/>
) : (
<NoTemplateDialog
fix={activeFix}
onClose={() => setScriptPanelOpen(false)}
onDecide={handleScriptDecision}
busy={scriptDecisionBusy}
/>
)
)}
<div className="flex items-center gap-3 px-3 mt-1"> <div className="flex items-center gap-3 px-3 mt-1">
<button <button
onClick={() => handleOpenPreview('resolve')} onClick={() => handleOpenPreview('resolve')}