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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)",
|
||||
|
||||
201
backend/app/services/template_extraction_service.py
Normal file
201
backend/app/services/template_extraction_service.py
Normal 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}
|
||||
277
backend/tests/test_phase5_inline_script.py
Normal file
277
backend/tests/test_phase5_inline_script.py
Normal 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"
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
> **Target:** Transform `/assistant` (ResolutionAssist) into the new unified `/pilot` (FlowPilot) surface.
|
||||
> **Audience:** Claude Code (implementation) and Codex (review) reviewed by Michael (owner).
|
||||
> **Status:** Phases 0–4 implemented and verified end-to-end against the dev stack. Phase 5 next.
|
||||
> **Last updated:** April 22, 2026 (Phase 4 — Resolve + Escalate writebacks — committed; local-only and mocked-PSA paths verified, live CW ticket round-trip pending a test instance)
|
||||
> **Status:** Phases 0–5 implemented and verified end-to-end against the dev stack. Phase 6 next.
|
||||
> **Last updated:** April 22, 2026 (Phase 5 — inline 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.
|
||||
- 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"
|
||||
```
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface DecisionResponse {
|
||||
id: string
|
||||
user_decision: UserDecision
|
||||
rendered_script: string | null
|
||||
draft_template_id: string | null
|
||||
redirect_path: string | null
|
||||
}
|
||||
|
||||
@@ -69,10 +70,18 @@ export const sessionSuggestedFixesApi = {
|
||||
sessionId: string,
|
||||
fixId: string,
|
||||
decision: UserDecision,
|
||||
options?: {
|
||||
editedScript?: string
|
||||
parametersUsed?: Record<string, unknown>
|
||||
},
|
||||
): Promise<DecisionResponse> {
|
||||
const r = await apiClient.post<DecisionResponse>(
|
||||
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/decision`,
|
||||
{ decision },
|
||||
{
|
||||
decision,
|
||||
edited_script: options?.editedScript,
|
||||
parameters_used: options?.parametersUsed,
|
||||
},
|
||||
)
|
||||
return r.data
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Search, Loader2, ArrowRight, FileText, Clock,
|
||||
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' },
|
||||
]
|
||||
|
||||
// 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 }) {
|
||||
const cls = cn('shrink-0', className)
|
||||
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) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
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 [query, setQuery] = useState('')
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
@@ -167,7 +190,10 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
||||
if (recentItems.length > 0) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -278,6 +304,12 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
||||
|
||||
const handleSelect = useCallback((item: PaletteItem) => {
|
||||
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') {
|
||||
navigate(item.path, { state: { prefill: query.trim() } })
|
||||
} else {
|
||||
|
||||
208
frontend/src/components/pilot/script/NoTemplateDialog.tsx
Normal file
208
frontend/src/components/pilot/script/NoTemplateDialog.tsx
Normal 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
|
||||
136
frontend/src/components/pilot/script/ParameterizationPreview.tsx
Normal file
136
frontend/src/components/pilot/script/ParameterizationPreview.tsx
Normal 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
|
||||
254
frontend/src/components/pilot/script/TemplateMatchPanel.tsx
Normal file
254
frontend/src/components/pilot/script/TemplateMatchPanel.tsx
Normal 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
|
||||
@@ -11,11 +11,18 @@
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Sparkles, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SessionSuggestedFix } from '@/api/sessionSuggestedFixes'
|
||||
|
||||
interface SuggestedFixProps {
|
||||
fix: SessionSuggestedFix
|
||||
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 } {
|
||||
@@ -24,11 +31,12 @@ function confidenceBucket(pct: number): { label: string; tone: string } {
|
||||
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 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)
|
||||
try { await onDismiss() } finally { setBusy(false) }
|
||||
}
|
||||
@@ -44,7 +52,14 @@ export function SuggestedFix({ fix, onDismiss }: SuggestedFixProps) {
|
||||
</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">
|
||||
<Sparkles size={14} className="text-warning shrink-0 mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -56,12 +71,12 @@ export function SuggestedFix({ fix, onDismiss }: SuggestedFixProps) {
|
||||
</div>
|
||||
{fix.script_template_id && (
|
||||
<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>
|
||||
)}
|
||||
{!fix.script_template_id && fix.ai_drafted_script && (
|
||||
<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>
|
||||
|
||||
@@ -16,11 +16,15 @@ import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane'
|
||||
import { WhatWeKnow } from '@/components/pilot/sections/WhatWeKnow'
|
||||
import { SuggestedFix } from '@/components/pilot/sections/SuggestedFix'
|
||||
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 {
|
||||
sessionSuggestedFixesApi,
|
||||
type SessionSuggestedFix,
|
||||
type ResolutionNotePreview as ResolutionNotePreviewData,
|
||||
type UserDecision,
|
||||
} from '@/api/sessionSuggestedFixes'
|
||||
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
|
||||
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
||||
@@ -100,6 +104,11 @@ export default function AssistantChatPage() {
|
||||
// dups, but the request itself still costs HTTP RTT).
|
||||
const previewDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(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 toggleSidebarCollapse = () => {
|
||||
const next = !sidebarCollapsed
|
||||
@@ -234,6 +243,21 @@ export default function AssistantChatPage() {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [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 () => {
|
||||
try {
|
||||
const sessions = await aiSessionsApi.listSessions({ session_type: 'chat', limit: 100 })
|
||||
@@ -277,7 +301,13 @@ export default function AssistantChatPage() {
|
||||
try {
|
||||
const fix = await sessionSuggestedFixesApi.getActive(chatId)
|
||||
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 {
|
||||
// No-fix-yet (404) is normalized to null inside the client. Genuine
|
||||
// failures stay silent — accessory state, not load-bearing.
|
||||
@@ -368,6 +398,7 @@ export default function AssistantChatPage() {
|
||||
try {
|
||||
await sessionSuggestedFixesApi.recordDecision(activeChatId, activeFix.id, 'dismissed')
|
||||
setActiveFix(null)
|
||||
setScriptPanelOpen(false)
|
||||
// Dismissal bumps state_version on the server; reflect in preview.
|
||||
schedulePreviewRefresh(activeChatId)
|
||||
} 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') => {
|
||||
if (!activeChatId) return
|
||||
// Opening a different kind clobbers the cached markdown so the popover
|
||||
@@ -447,6 +513,7 @@ export default function AssistantChatPage() {
|
||||
setPreviewData(null)
|
||||
setPreviewError(null)
|
||||
setPreviewKind(null)
|
||||
setScriptPanelOpen(false)
|
||||
// Fire facts + active-fix fetches in parallel with session detail.
|
||||
refreshSessionDerived(chatId)
|
||||
try {
|
||||
@@ -496,6 +563,7 @@ export default function AssistantChatPage() {
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
setFacts([])
|
||||
setScriptPanelOpen(false)
|
||||
setMessages([])
|
||||
setActiveSessionStatus('active')
|
||||
setActivePsaTicketId(null)
|
||||
@@ -1260,11 +1328,32 @@ export default function AssistantChatPage() {
|
||||
}
|
||||
suggestedFixSlot={
|
||||
activeFix && (
|
||||
<SuggestedFix fix={activeFix} onDismiss={handleDismissFix} />
|
||||
<SuggestedFix
|
||||
fix={activeFix}
|
||||
onDismiss={handleDismissFix}
|
||||
onActivate={() => setScriptPanelOpen((prev) => !prev)}
|
||||
panelOpen={scriptPanelOpen}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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">
|
||||
<button
|
||||
onClick={() => handleOpenPreview('resolve')}
|
||||
|
||||
Reference in New Issue
Block a user