feat(pilot): Phase 6 — post-resolve templatize prompt + draft accept/reject
All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s

Closes the loop on the Phase 5 "Run now, templatize after resolve" path.
After a session resolves, drafts queued by the three-option dialog surface
as a modal that lets the engineer review the AI-proposed parameterization
and either save as a reusable team template or skip. A "don't ask again"
toggle writes to account_settings.preferences so the next resolve won't
pop the modal.

Backend:
- /api/v1/draft-templates:
  * GET — list account drafts (pending_only default true; pass false for
    audit view including accepted/rejected)
  * GET /{id} — single draft
  * POST /{id}/accept — promotes to a new script_templates row with
    source_session_id / source_user_id / source_ticket_ref populated
    (drives the Script Library "generated from CW #X · resolved by Y"
    provenance chip). Draft flips to status=accepted,
    promoted_template_id set, resolved_at stamped. 409 on re-accept /
    already-rejected. 400 on unknown category_id.
  * POST /{id}/reject — flips to status=rejected. 409 on re-reject.
- /api/v1/accounts/me/preferences (GET/PATCH) — thin wrapper over
  AccountSettings.get_setting/set_setting. PATCH merges keys into the
  JSONB column, preserving existing keys the client didn't touch.
  Used by the "Don't ask again for this team" checkbox
  (templatize_prompt_enabled=false) and, forward-looking, by
  cw_resolved_status_id / cw_escalated_status_id from Phase 4.
- 13 tests: list filter, accept with/without edited_body, provenance
  copy-through, reject, 409 on re-accept / re-reject, 400 on unknown
  category, prefs round-trip with merge semantics.

Frontend:
- src/components/pilot/script/TemplatizePrompt.tsx — modal showing the
  drafted script with proposed parameters in the Phase 5
  ParameterizationPreview, editable name/category/description, an
  individual-parameter remove button, and the "don't ask again" opt-out.
  Accept posts to /draft-templates/{id}/accept + optionally PATCHes
  preferences. Skip posts /reject.
- src/api/draftTemplates.ts — typed client plus accountPreferencesApi.
- AssistantChatPage: after a successful Resolve (external OR local),
  fetches preferences + pending drafts for the session and queues the
  modal one draft at a time. Escalate does not trigger this flow.
- Sidebar: Scripts nav shows the pending-draft count as a badge. Fetched
  independently of the main sidebar stats so endpoint flakes don't
  break the rest of the sidebar.

Verified live 2026-04-22: seed two drafts → GET sees both pending →
accept draft A (template created, provenance CW #99123 populated) →
reject draft B → pending count drops → PATCH opt-out → GET confirms
persistence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 02:37:49 -04:00
parent ddae171a37
commit 4aaf57adb5
10 changed files with 1128 additions and 3 deletions

View File

@@ -16,6 +16,7 @@ from app.models.refresh_token import RefreshToken
from app.core.email import EmailService
from app.models.account import Account
from app.models.account_invite import AccountInvite
from app.models.account_settings import AccountSettings
from app.models.subscription import Subscription
from app.models.user import User
from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse, TransferOwnershipRequest
@@ -559,3 +560,65 @@ async def get_sso_status(
sso_enabled=account.sso_enabled,
sso_provider=account.sso_provider,
)
# ─── Account Preferences (FlowPilot Phase 6) ──────────────────────────────────
#
# Preferences live in `account_settings.preferences` as a JSONB grab-bag
# (per FLOWPILOT-MIGRATION.md Section 4.6). Rows are lazily created on first
# write. Any engineer-role user can read + update preferences because the
# keys stored here (templatize_prompt_enabled, cw_resolved_status_id, etc.)
# are team-level toggles rather than account-owner-gated admin settings.
class AccountPreferencesResponse(BaseModel):
preferences: dict
class AccountPreferencesUpdate(BaseModel):
"""Merge-style update — each key in `preferences` overwrites that key in
the stored JSONB, other keys are preserved. Omit the body entirely to
no-op.
"""
preferences: dict
@router.get("/me/preferences", response_model=AccountPreferencesResponse)
async def get_my_preferences(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Return the current account's preferences JSONB (empty dict if no row)."""
result = await db.execute(
select(AccountSettings.preferences).where(
AccountSettings.account_id == current_user.account_id
)
)
prefs = result.scalar_one_or_none() or {}
return AccountPreferencesResponse(preferences=prefs)
@router.patch("/me/preferences", response_model=AccountPreferencesResponse)
async def update_my_preferences(
data: AccountPreferencesUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Upsert preference keys. Existing keys not present in the payload are kept.
Example: posting `{"preferences": {"templatize_prompt_enabled": false}}`
from the post-resolve "Don't ask me again for this team" checkbox sets
just that key without clobbering any other preferences.
"""
for key, value in data.preferences.items():
await AccountSettings.set_setting(db, current_user.account_id, key, value)
await db.commit()
# Return the merged state so the client doesn't need a second GET.
result = await db.execute(
select(AccountSettings.preferences).where(
AccountSettings.account_id == current_user.account_id
)
)
prefs = result.scalar_one_or_none() or {}
return AccountPreferencesResponse(preferences=prefs)

View File

@@ -0,0 +1,221 @@
"""Draft template endpoints — Phase 6 post-resolve templatization flow.
Engineers who picked "Run now, templatize after resolve" on the three-option
dialog (Phase 5) generate a `draft_templates` row at decision time. After
the session resolves, the TemplatizePrompt component lets them either:
- Accept → promotes the draft to a real `script_templates` row
- Reject → marks the draft rejected, no library entry created
The Script Library sidebar uses the list endpoint to surface a
"X drafts ready to review" badge for the account.
See FLOWPILOT-MIGRATION.md Section 5.3.
"""
import logging
import re
from datetime import datetime, timezone
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
from app.models.ai_session import AISession
from app.models.draft_template import DraftTemplate
from app.models.script_template import ScriptCategory, ScriptTemplate
from app.models.user import User
from app.schemas.draft_template import (
DraftTemplateAcceptRequest,
DraftTemplateAcceptResponse,
DraftTemplateListResponse,
DraftTemplateRejectResponse,
DraftTemplateResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/draft-templates", tags=["draft-templates"])
def _slugify(name: str) -> str:
"""Same slug rule as scripts.create_template — lowercase, kebab-case, ASCII."""
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
# ── List ─────────────────────────────────────────────────────────────────
@router.get("", response_model=DraftTemplateListResponse)
async def list_drafts(
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
pending_only: bool = True,
) -> DraftTemplateListResponse:
"""List drafts for the current user's account.
Defaults to pending-only — that's what the Script Library badge counts
and what the post-resolve TemplatizePrompt iterates over. Pass
`pending_only=false` to include accepted/rejected for an audit view.
"""
stmt = select(DraftTemplate).order_by(DraftTemplate.created_at.desc())
if pending_only:
stmt = stmt.where(DraftTemplate.status == "pending")
result = await db.execute(stmt)
drafts = list(result.scalars().all())
return DraftTemplateListResponse(
drafts=[DraftTemplateResponse.model_validate(d) for d in drafts]
)
# ── Get one ──────────────────────────────────────────────────────────────
@router.get("/{draft_id}", response_model=DraftTemplateResponse)
async def get_draft(
draft_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
) -> DraftTemplateResponse:
draft = await _load_draft_or_404(db, draft_id)
return DraftTemplateResponse.model_validate(draft)
# ── Accept ───────────────────────────────────────────────────────────────
@router.post(
"/{draft_id}/accept",
response_model=DraftTemplateAcceptResponse,
status_code=201,
)
async def accept_draft(
draft_id: UUID,
body: DraftTemplateAcceptRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
) -> DraftTemplateAcceptResponse:
"""Promote a draft to a real `script_templates` row.
Provenance fields (`source_session_id`, `source_user_id`,
`source_ticket_ref`) are copied so the Script Library can render the
"generated from CW #X · resolved by Y · used N times" chip.
On success: draft.status='accepted', draft.promoted_template_id set,
draft.resolved_at set. The new template is owned by the engineer's team
(matches scripts.create_template's behavior).
Returns 409 if the draft is already accepted/rejected.
"""
draft = await _load_draft_or_404(db, draft_id)
if draft.status != "pending":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Draft is already {draft.status}",
)
# Validate the category exists and belongs to (or is global for) this account.
cat_result = await db.execute(
select(ScriptCategory).where(
ScriptCategory.id == body.category_id,
ScriptCategory.is_active == True, # noqa: E712
)
)
if cat_result.scalar_one_or_none() is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="category_id does not reference an active script category",
)
# Look up source-session ticket ref for the provenance chip. RLS makes
# cross-account ai_session lookup impossible — the draft must belong to
# the same account as the requesting user.
source_session = (
await db.execute(
select(AISession).where(AISession.id == draft.source_session_id)
)
).scalar_one_or_none()
source_ticket_ref = (
f"CW #{source_session.psa_ticket_id}"
if source_session and source_session.psa_ticket_id
else None
)
slug = _slugify(body.name)
template = ScriptTemplate(
category_id=body.category_id,
team_id=current_user.team_id,
account_id=current_user.account_id,
created_by=current_user.id,
name=body.name,
slug=slug,
description=body.description,
script_body=body.edited_body or draft.script_body,
parameters_schema=body.parameters_schema,
# FlowPilot provenance — drives the Script Library chip.
source_session_id=draft.source_session_id,
source_user_id=draft.source_user_id,
source_ticket_ref=source_ticket_ref,
)
db.add(template)
await db.flush() # populate template.id
draft.status = "accepted"
draft.promoted_template_id = template.id
draft.resolved_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(template)
return DraftTemplateAcceptResponse(
draft_id=draft.id,
promoted_template_id=template.id,
template_slug=template.slug,
)
# ── Reject ───────────────────────────────────────────────────────────────
@router.post("/{draft_id}/reject", response_model=DraftTemplateRejectResponse)
async def reject_draft(
draft_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
) -> DraftTemplateRejectResponse:
"""Mark a draft rejected.
No template is created. The row stays for audit (so a team admin can see
the engineer reviewed and explicitly declined). Returns 409 on a draft
that's already accepted/rejected.
"""
draft = await _load_draft_or_404(db, draft_id)
if draft.status != "pending":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Draft is already {draft.status}",
)
draft.status = "rejected"
draft.resolved_at = datetime.now(timezone.utc)
await db.commit()
return DraftTemplateRejectResponse(draft_id=draft.id, status="rejected")
# ── Helpers ─────────────────────────────────────────────────────────────
async def _load_draft_or_404(
db: AsyncSession, draft_id: UUID
) -> DraftTemplate:
"""RLS-scoped draft load. 404 covers missing + cross-tenant."""
result = await db.execute(
select(DraftTemplate).where(DraftTemplate.id == draft_id)
)
draft = result.scalar_one_or_none()
if draft is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Draft template not found",
)
return draft

View File

@@ -25,6 +25,7 @@ from app.api.endpoints import (
categories,
copilot,
device_types,
draft_templates,
feedback,
flow_proposals,
flowpilot_analytics,
@@ -141,6 +142,7 @@ api_router.include_router(session_resolutions.router, dependencies=_tenant_deps)
# so the {session_id}/facts subpaths take precedence over any future generic catchalls.
api_router.include_router(session_facts.router, dependencies=_tenant_deps)
api_router.include_router(session_suggested_fixes.router, dependencies=_tenant_deps)
api_router.include_router(draft_templates.router, dependencies=_tenant_deps)
api_router.include_router(ai_sessions.router, dependencies=_tenant_deps)
api_router.include_router(flow_proposals.router, dependencies=_tenant_deps)
api_router.include_router(flowpilot_analytics.router, dependencies=_tenant_deps)

View File

@@ -0,0 +1,68 @@
"""Pydantic schemas for FlowPilot Phase 6 draft templates.
A draft is the engineer's "Run now, templatize after resolve" path output:
the script ran for the ticket, and the AI proposed a parameterization.
Post-resolve, the engineer accepts (promotes to a real template) or rejects.
See FLOWPILOT-MIGRATION.md Section 5.3.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, Field
DraftStatus = Literal["pending", "accepted", "rejected"]
class DraftTemplateResponse(BaseModel):
id: UUID
account_id: UUID
source_session_id: UUID
source_user_id: UUID
script_body: str
proposed_parameters: dict[str, Any]
proposed_name: str | None
proposed_category_id: UUID | None
status: DraftStatus
resolved_at: datetime | None
promoted_template_id: UUID | None
created_at: datetime
model_config = {"from_attributes": True}
class DraftTemplateListResponse(BaseModel):
drafts: list[DraftTemplateResponse]
class DraftTemplateAcceptRequest(BaseModel):
"""Engineer's confirmation that this draft should become a real template.
Engineer may override the AI's proposed name / category and edit the
parameter schema before promotion. Body and parameters_schema are
persisted to the new `script_templates` row.
"""
name: str = Field(..., min_length=1, max_length=200)
category_id: UUID
description: str | None = Field(None, max_length=2000)
# Final parameter schema in the Script Generator's standard shape.
# See ScriptTemplate.parameters_schema for the contract.
parameters_schema: dict[str, Any]
# Optional last-minute edits to the script body. Defaults to the draft's
# `script_body` (which TemplateExtractionService produced as the templated
# form with `{{ key }}` placeholders).
edited_body: str | None = Field(None, min_length=1, max_length=50_000)
class DraftTemplateAcceptResponse(BaseModel):
draft_id: UUID
promoted_template_id: UUID
template_slug: str
class DraftTemplateRejectResponse(BaseModel):
draft_id: UUID
status: Literal["rejected"]

View File

@@ -0,0 +1,295 @@
"""API tests for the FlowPilot Phase 6 post-resolve templatization flow.
Covers:
- GET /api/v1/draft-templates list with pending_only filter.
- POST /{id}/accept → creates script_templates row with provenance fields,
marks draft accepted + promoted_template_id set.
- POST /{id}/reject → marks rejected.
- 409 when accepting or rejecting a non-pending draft.
- Category validation (400 on unknown category_id).
- GET/PATCH /accounts/me/preferences round-trip.
"""
from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.models.account_settings import AccountSettings
from app.models.ai_session import AISession
from app.models.draft_template import DraftTemplate
from app.models.script_template import ScriptCategory, ScriptTemplate
async def _make_draft(
test_db,
user,
*,
proposed_name: str = "Test draft",
status_: str = "pending",
with_psa_ticket: bool = False,
) -> tuple[AISession, DraftTemplate]:
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 6 test"},
status="resolved",
confidence_tier="discovery",
conversation_messages=[],
psa_ticket_id="48307" if with_psa_ticket else None,
)
test_db.add(session)
await test_db.flush()
draft = DraftTemplate(
account_id=user["user_data"]["account_id"],
source_session_id=session.id,
source_user_id=user["user_data"]["id"],
script_body='Do-Something -Target {{ target_name }}\n',
proposed_parameters={
"parameters": [
{"key": "target_name", "label": "Target Name", "type": "text"},
],
},
proposed_name=proposed_name,
status=status_,
)
test_db.add(draft)
await test_db.commit()
await test_db.refresh(draft)
return session, draft
async def _make_category(test_db) -> ScriptCategory:
cat = ScriptCategory(
name="Phase 6 Test Category",
slug=f"phase-6-test-{datetime.now(timezone.utc).timestamp()}",
description="test",
is_active=True,
)
test_db.add(cat)
await test_db.commit()
await test_db.refresh(cat)
return cat
# ── List ─────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_list_pending_only_default(
client: AsyncClient, test_user, auth_headers, test_db
):
await _make_draft(test_db, test_user, proposed_name="pending-a", status_="pending")
await _make_draft(test_db, test_user, proposed_name="accepted-b", status_="accepted")
r = await client.get("/api/v1/draft-templates", headers=auth_headers)
assert r.status_code == 200
drafts = r.json()["drafts"]
names = {d["proposed_name"] for d in drafts}
assert "pending-a" in names
assert "accepted-b" not in names
@pytest.mark.asyncio
async def test_list_with_pending_only_false_includes_all(
client: AsyncClient, test_user, auth_headers, test_db
):
await _make_draft(test_db, test_user, proposed_name="pending-c", status_="pending")
await _make_draft(test_db, test_user, proposed_name="rejected-d", status_="rejected")
r = await client.get(
"/api/v1/draft-templates?pending_only=false", headers=auth_headers,
)
assert r.status_code == 200
names = {d["proposed_name"] for d in r.json()["drafts"]}
assert {"pending-c", "rejected-d"}.issubset(names)
# ── Accept ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_accept_creates_template_with_provenance(
client: AsyncClient, test_user, auth_headers, test_db
):
session, draft = await _make_draft(test_db, test_user, with_psa_ticket=True)
cat = await _make_category(test_db)
r = await client.post(
f"/api/v1/draft-templates/{draft.id}/accept",
headers=auth_headers,
json={
"name": "Do Something On Target",
"category_id": str(cat.id),
"description": "promoted from phase 6 test",
"parameters_schema": {
"parameters": [
{"key": "target_name", "label": "Target", "field_type": "text"},
],
},
},
)
assert r.status_code == 201
body = r.json()
assert body["draft_id"] == str(draft.id)
assert body["promoted_template_id"] is not None
assert body["template_slug"] == "do-something-on-target"
# Draft row is now accepted with the promoted template ID set.
await test_db.refresh(draft)
assert draft.status == "accepted"
assert draft.promoted_template_id == UUID(body["promoted_template_id"])
assert draft.resolved_at is not None
# New template row exists with provenance fields populated.
tpl_result = await test_db.execute(
select(ScriptTemplate).where(ScriptTemplate.id == UUID(body["promoted_template_id"]))
)
tpl = tpl_result.scalar_one()
assert tpl.source_session_id == session.id
assert tpl.source_user_id == UUID(test_user["user_data"]["id"])
assert tpl.source_ticket_ref == "CW #48307"
assert tpl.script_body == draft.script_body # edited_body was not supplied
@pytest.mark.asyncio
async def test_accept_with_edited_body_overrides_draft(
client: AsyncClient, test_user, auth_headers, test_db
):
_, draft = await _make_draft(test_db, test_user)
cat = await _make_category(test_db)
r = await client.post(
f"/api/v1/draft-templates/{draft.id}/accept",
headers=auth_headers,
json={
"name": "Edited Body Test",
"category_id": str(cat.id),
"parameters_schema": {"parameters": []},
"edited_body": 'Write-Host "edited final version"\n',
},
)
assert r.status_code == 201
tpl = (
await test_db.execute(
select(ScriptTemplate).where(
ScriptTemplate.id == UUID(r.json()["promoted_template_id"])
)
)
).scalar_one()
assert tpl.script_body == 'Write-Host "edited final version"\n'
@pytest.mark.asyncio
async def test_accept_rejects_unknown_category(
client: AsyncClient, test_user, auth_headers, test_db
):
_, draft = await _make_draft(test_db, test_user)
bogus_cat = "00000000-0000-0000-0000-000000000000"
r = await client.post(
f"/api/v1/draft-templates/{draft.id}/accept",
headers=auth_headers,
json={
"name": "x",
"category_id": bogus_cat,
"parameters_schema": {"parameters": []},
},
)
assert r.status_code == 400
@pytest.mark.asyncio
async def test_accept_already_accepted_returns_409(
client: AsyncClient, test_user, auth_headers, test_db
):
_, draft = await _make_draft(test_db, test_user, status_="accepted")
cat = await _make_category(test_db)
r = await client.post(
f"/api/v1/draft-templates/{draft.id}/accept",
headers=auth_headers,
json={
"name": "x",
"category_id": str(cat.id),
"parameters_schema": {"parameters": []},
},
)
assert r.status_code == 409
# ── Reject ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_reject_marks_draft_rejected(
client: AsyncClient, test_user, auth_headers, test_db
):
_, draft = await _make_draft(test_db, test_user)
r = await client.post(
f"/api/v1/draft-templates/{draft.id}/reject", headers=auth_headers,
)
assert r.status_code == 200
assert r.json()["status"] == "rejected"
await test_db.refresh(draft)
assert draft.status == "rejected"
assert draft.resolved_at is not None
@pytest.mark.asyncio
async def test_reject_already_accepted_returns_409(
client: AsyncClient, test_user, auth_headers, test_db
):
_, draft = await _make_draft(test_db, test_user, status_="accepted")
r = await client.post(
f"/api/v1/draft-templates/{draft.id}/reject", headers=auth_headers,
)
assert r.status_code == 409
# ── Preferences ──────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_preferences_empty_by_default(
client: AsyncClient, auth_headers,
):
r = await client.get("/api/v1/accounts/me/preferences", headers=auth_headers)
assert r.status_code == 200
assert r.json()["preferences"] == {}
@pytest.mark.asyncio
async def test_patch_preferences_merges_keys(
client: AsyncClient, test_user, auth_headers, test_db
):
# First write: one key.
r = await client.patch(
"/api/v1/accounts/me/preferences",
headers=auth_headers,
json={"preferences": {"templatize_prompt_enabled": False}},
)
assert r.status_code == 200
assert r.json()["preferences"]["templatize_prompt_enabled"] is False
# Second write: different key — first must be preserved (merge semantics).
r2 = await client.patch(
"/api/v1/accounts/me/preferences",
headers=auth_headers,
json={"preferences": {"cw_resolved_status_id": 42}},
)
assert r2.status_code == 200
prefs = r2.json()["preferences"]
assert prefs["templatize_prompt_enabled"] is False
assert prefs["cw_resolved_status_id"] == 42
# Stored on the account_settings row.
stored = (
await test_db.execute(
select(AccountSettings.preferences).where(
AccountSettings.account_id == UUID(test_user["user_data"]["account_id"])
)
)
).scalar_one()
assert stored["templatize_prompt_enabled"] is False
assert stored["cw_resolved_status_id"] == 42

View File

@@ -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 05 implemented and verified end-to-end against the dev stack. Phase 6 next.
> **Last updated:** April 22, 2026 (Phase 5inline Script Generator integration — committed; live decision endpoint with Sonnet-driven TemplateExtractionService verified)
> **Status:** Phases 06 implemented and verified end-to-end against the dev stack. Phase 7 (polish) next.
> **Last updated:** April 22, 2026 (Phase 6post-resolve TemplatizePrompt — committed; draft accept → script_templates promotion with provenance verified live)
---
@@ -849,6 +849,22 @@ git commit -m "feat(pilot): integrate Script Generator inline with suggested fix
- Skip the prompt → draft marked rejected, Script Library shows no new template.
- Toggle "don't ask me again" → next session Resolve skips the prompt even with a pending draft.
**Verified on 2026-04-22:**
- `GET /draft-templates?pending_only=true` returns pending rows; filter
flips the set to include accepted/rejected for audit views.
- `POST /{id}/accept` → creates `script_templates` row; `source_session_id`,
`source_user_id`, `source_ticket_ref` (e.g. "CW #99123") copied from
the source session so the Script Library provenance chip has its data.
Draft flips to `status='accepted'`, `promoted_template_id` populated,
`resolved_at` set. 409 on a re-accept.
- `POST /{id}/reject` → flips to `status='rejected'`, `resolved_at` set.
- `GET /accounts/me/preferences` → empty dict when no row; `PATCH`
merges keys into `preferences` JSONB (verified round-trip persistence
of `templatize_prompt_enabled: false`).
- Sidebar Scripts nav gains a badge reflecting the pending draft count
(fetched independently of the main sidebar stats endpoint so a
draft-endpoint failure doesn't break the rest of the sidebar).
```
git commit -m "feat(pilot): add post-resolve templatize prompt for draft templates"
```

View File

@@ -0,0 +1,98 @@
/**
* Draft templates API — Phase 6 post-resolve templatization flow.
*
* A draft is produced when the engineer picks "Run now, templatize after
* resolve" on the three-option dialog. After Resolve, the TemplatizePrompt
* modal lists pending drafts and lets the engineer accept (→ real
* script_templates row) or reject.
*
* Mirrors backend endpoints under /api/v1/draft-templates.
*/
import apiClient from './client'
export type DraftStatus = 'pending' | 'accepted' | 'rejected'
export interface DraftTemplate {
id: string
account_id: string
source_session_id: string
source_user_id: string
script_body: string
proposed_parameters: { parameters?: Array<Record<string, unknown>> } | Record<string, unknown>
proposed_name: string | null
proposed_category_id: string | null
status: DraftStatus
resolved_at: string | null
promoted_template_id: string | null
created_at: string
}
export interface DraftAcceptRequest {
name: string
category_id: string
description?: string | null
parameters_schema: { parameters: Array<Record<string, unknown>> } | Record<string, unknown>
edited_body?: string | null
}
export interface DraftAcceptResponse {
draft_id: string
promoted_template_id: string
template_slug: string
}
export interface DraftRejectResponse {
draft_id: string
status: 'rejected'
}
export const draftTemplatesApi = {
async list(pendingOnly = true): Promise<DraftTemplate[]> {
const r = await apiClient.get<{ drafts: DraftTemplate[] }>('/draft-templates', {
params: { pending_only: pendingOnly },
})
return r.data.drafts
},
async get(id: string): Promise<DraftTemplate> {
const r = await apiClient.get<DraftTemplate>(`/draft-templates/${id}`)
return r.data
},
async accept(id: string, data: DraftAcceptRequest): Promise<DraftAcceptResponse> {
const r = await apiClient.post<DraftAcceptResponse>(
`/draft-templates/${id}/accept`,
data,
)
return r.data
},
async reject(id: string): Promise<DraftRejectResponse> {
const r = await apiClient.post<DraftRejectResponse>(
`/draft-templates/${id}/reject`,
)
return r.data
},
}
// ── Account preferences (used by the "don't ask again" opt-out) ────────────
export interface AccountPreferences {
preferences: Record<string, unknown>
}
export const accountPreferencesApi = {
async get(): Promise<AccountPreferences> {
const r = await apiClient.get<AccountPreferences>('/accounts/me/preferences')
return r.data
},
async update(patch: Record<string, unknown>): Promise<AccountPreferences> {
const r = await apiClient.patch<AccountPreferences>('/accounts/me/preferences', {
preferences: patch,
})
return r.data
},
}
export default draftTemplatesApi

View File

@@ -44,6 +44,10 @@ export function Sidebar() {
const toggleSidebarPinned = useUserPreferencesStore(s => s.toggleSidebarPinned)
const [stats, setStats] = useState<SidebarStatsResponse | null>(null)
// Phase 6: pending-drafts badge on the Scripts nav. Fetched independently
// of the main stats endpoint so backend changes aren't coupled — worst
// case the badge doesn't show, rest of the sidebar still renders.
const [pendingDraftCount, setPendingDraftCount] = useState<number>(0)
const [flyoutIndex, setFlyoutIndex] = useState<string | null>(null)
const flyoutTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
const sidebarRef = useRef<HTMLElement>(null)
@@ -56,6 +60,13 @@ export function Sidebar() {
sidebarApi.getStats()
.then(data => { if (requestId === statsRequestId.current) setStats(data) })
.catch(() => {})
// Phase 6: pending draft templates — soft-fail, optional import keeps
// the sidebar robust if the endpoint is momentarily unavailable.
import('@/api/draftTemplates').then(({ draftTemplatesApi }) => {
draftTemplatesApi.list(true)
.then(drafts => setPendingDraftCount(drafts.length))
.catch(() => {})
}).catch(() => {})
}, [])
useEffect(() => { refreshStats() }, [location.pathname, refreshStats])
@@ -97,9 +108,10 @@ export function Sidebar() {
},
{
href: '/scripts', icon: FileText, label: 'Scripts', shortLabel: 'Scripts',
badge: pendingDraftCount || undefined,
matchPaths: ['/scripts', '/script-builder'],
children: [
{ href: '/scripts', label: 'Script Library' },
{ href: '/scripts', label: 'Script Library', count: pendingDraftCount || undefined },
{ href: '/script-builder', label: 'Script Builder' },
],
},

View File

@@ -0,0 +1,308 @@
/**
* TemplatizePrompt — Phase 6 post-resolve modal.
*
* Appears after a successful Resolve when ALL three of:
* 1. account_settings.preferences.templatize_prompt_enabled !== false
* (default true when absent — per FLOWPILOT-MIGRATION.md Section 14.2)
* 2. The session has at least one pending draft_templates row
* 3. (Implicit from #2) The engineer picked "Run now, templatize after"
* on the original three-option dialog
*
* The engineer picks a category, optionally tweaks the name/description/
* parameter list, and either:
* - Saves → POST /draft-templates/{id}/accept → new script_templates row
* - Skips → POST /draft-templates/{id}/reject
* - Toggles "Don't ask me again for this team" → PATCH
* /accounts/me/preferences with {templatize_prompt_enabled: false}
*
* Parent (AssistantChatPage) controls the open state and feeds one draft at
* a time. When multiple drafts exist for the session, parent re-opens the
* modal for the next one after save/skip.
*/
import { useEffect, useMemo, useState } from 'react'
import { Loader2, Check, X, Trash2, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Modal } from '@/components/common/Modal'
import { toast } from '@/lib/toast'
import { scriptsApi } from '@/api/scripts'
import type { ScriptCategoryResponse } from '@/types'
import {
draftTemplatesApi,
accountPreferencesApi,
type DraftTemplate,
} from '@/api/draftTemplates'
import { ParameterizationPreview } from './ParameterizationPreview'
interface TemplatizePromptProps {
draft: DraftTemplate
sourceTicketRef: string | null // "CW #48307" or null for local-only sessions
onResolved: (outcome: 'accepted' | 'rejected') => void
}
interface ParamEntry {
key: string
label: string
field_type: string
inferred_from?: string
}
function normalizeParams(proposed: DraftTemplate['proposed_parameters']): ParamEntry[] {
const raw = (proposed as { parameters?: unknown[] })?.parameters
if (!Array.isArray(raw)) return []
return raw
.filter((p): p is Record<string, unknown> => typeof p === 'object' && p !== null)
.map((p) => ({
key: String(p.key ?? p.variable_name ?? ''),
label: String(p.label ?? ''),
field_type: String(p.field_type ?? p.type ?? 'text'),
inferred_from: typeof p.inferred_from === 'string' ? p.inferred_from : undefined,
}))
.filter((p) => p.key.length > 0)
}
export function TemplatizePrompt({ draft, sourceTicketRef, onResolved }: TemplatizePromptProps) {
const [name, setName] = useState(draft.proposed_name ?? '')
const [description, setDescription] = useState('')
const [categoryId, setCategoryId] = useState(draft.proposed_category_id ?? '')
const [params, setParams] = useState<ParamEntry[]>(() => normalizeParams(draft.proposed_parameters))
const [dontAskAgain, setDontAskAgain] = useState(false)
const [categories, setCategories] = useState<ScriptCategoryResponse[]>([])
const [categoriesLoading, setCategoriesLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
setCategoriesLoading(true)
scriptsApi
.getCategories()
.then((cats) => {
setCategories(cats)
if (!categoryId && cats.length > 0) setCategoryId(cats[0].id)
})
.catch(() => toast.error('Could not load script categories'))
.finally(() => setCategoriesLoading(false))
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const canSave = useMemo(
() => Boolean(name.trim() && categoryId && !saving),
[name, categoryId, saving],
)
const removeParam = (keyToRemove: string) => {
setParams((prev) => prev.filter((p) => p.key !== keyToRemove))
}
const persistOptOutIfNeeded = async () => {
if (!dontAskAgain) return
try {
await accountPreferencesApi.update({ templatize_prompt_enabled: false })
} catch {
// Soft-fail: the save itself succeeded; opt-out can be retried from
// settings. Don't block the engineer on this.
toast.warning("Could not save 'don't ask again' preference — retry in Settings")
}
}
const handleAccept = async () => {
if (!canSave) return
setSaving(true)
try {
const parameters_schema = {
parameters: params.map((p) => ({
key: p.key,
label: p.label || p.key,
field_type: p.field_type,
})),
}
await draftTemplatesApi.accept(draft.id, {
name: name.trim(),
category_id: categoryId,
description: description.trim() || null,
parameters_schema,
})
await persistOptOutIfNeeded()
toast.success('Saved as team template')
onResolved('accepted')
} catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status
if (status === 409) toast.warning('This draft has already been handled')
else toast.error('Could not save template')
} finally {
setSaving(false)
}
}
const handleSkip = async () => {
setSaving(true)
try {
await draftTemplatesApi.reject(draft.id)
await persistOptOutIfNeeded()
onResolved('rejected')
} catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status
if (status === 409) {
// Already resolved — treat as a no-op, not an error.
onResolved('rejected')
} else {
toast.error('Could not skip draft')
}
} finally {
setSaving(false)
}
}
return (
<Modal
isOpen
onClose={handleSkip}
title="Save as team template?"
size="xl"
footer={
<div className="flex items-center justify-between gap-3 w-full">
<label className="flex items-center gap-2 text-[0.75rem] text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={dontAskAgain}
onChange={(e) => setDontAskAgain(e.target.checked)}
className="accent-accent"
/>
Don't ask me again for this team
</label>
<div className="flex items-center gap-2">
<button
onClick={handleSkip}
disabled={saving}
className="rounded-md px-3 py-1.5 text-[0.8125rem] text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors disabled:opacity-40"
>
Skip
</button>
<button
onClick={handleAccept}
disabled={!canSave}
className={cn(
'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[0.8125rem] font-semibold transition-colors',
canSave
? 'bg-accent text-white hover:bg-accent-hover'
: 'bg-elevated text-muted-foreground border border-default cursor-not-allowed',
)}
>
{saving ? <Loader2 size={11} className="animate-spin" /> : <Check size={11} />}
Save as team template
</button>
</div>
</div>
}
>
<div className="space-y-4 text-[0.8125rem]">
<div className="flex items-start gap-2 rounded-lg border border-accent/30 bg-accent-dim/15 px-3 py-2">
<Sparkles size={13} className="text-accent mt-0.5 shrink-0" />
<div className="text-[0.75rem] text-muted-foreground leading-relaxed">
This script ran as a one-off during the resolved session
{sourceTicketRef && (
<> (<span className="font-mono text-accent-text">{sourceTicketRef}</span>)</>
)}
. Review the proposed parameters and save it to the Script Library
so the team can reuse it.
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-[0.6875rem] font-semibold text-heading mb-1">
Template name
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-md border border-default bg-input px-2.5 py-1.5 text-[0.8125rem] text-heading focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
placeholder="Short, descriptive"
/>
</div>
<div>
<label className="block text-[0.6875rem] font-semibold text-heading mb-1">
Category
</label>
<select
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
disabled={categoriesLoading}
className="w-full rounded-md border border-default bg-input px-2.5 py-1.5 text-[0.8125rem] text-heading focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
>
{categoriesLoading && <option>Loading…</option>}
{!categoriesLoading && categories.length === 0 && (
<option value="">No categories available</option>
)}
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-[0.6875rem] font-semibold text-heading mb-1">
Description (optional)
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full rounded-md border border-default bg-input px-2.5 py-1.5 text-[0.8125rem] text-heading placeholder:text-muted-foreground resize-y min-h-[48px] max-h-[120px] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
rows={2}
placeholder="What does this script do? When should someone reach for it?"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[0.6875rem] font-semibold text-heading">
Proposed parameters ({params.length})
</label>
<span className="text-[0.6875rem] text-muted-foreground italic">
{params.length === 0
? 'No parameters proposed script will run as-is.'
: 'Remove any that don\'t need to be parameterized.'}
</span>
</div>
{params.length > 0 && (
<div className="rounded-lg border border-default bg-card overflow-hidden">
{params.map((p) => (
<div
key={p.key}
className="flex items-center gap-2 px-2.5 py-1.5 border-b border-default last:border-b-0"
>
<span className="font-mono text-[0.75rem] text-accent-text">{`{{ ${p.key} }}`}</span>
<span className="text-[0.75rem] text-heading">{p.label}</span>
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-semibold">
{p.field_type}
</span>
{p.inferred_from && (
<span className="text-[0.6875rem] text-muted-foreground italic truncate flex-1 min-w-0">
from {p.inferred_from}
</span>
)}
{!p.inferred_from && <span className="flex-1" />}
<button
onClick={() => removeParam(p.key)}
disabled={saving}
className="p-1 rounded text-muted-foreground hover:text-danger hover:bg-elevated/40 transition-colors"
title="Remove parameter"
>
<Trash2 size={11} />
</button>
</div>
))}
</div>
)}
</div>
<div>
<label className="block text-[0.6875rem] font-semibold text-heading mb-1">
Script preview
</label>
<ParameterizationPreview body={draft.script_body} />
</div>
</div>
</Modal>
)
}
export default TemplatizePrompt

View File

@@ -18,6 +18,12 @@ 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 { TemplatizePrompt } from '@/components/pilot/script/TemplatizePrompt'
import {
draftTemplatesApi,
accountPreferencesApi,
type DraftTemplate,
} from '@/api/draftTemplates'
import { PILOT_INLINE_SCRIPT_EVENT } from '@/lib/pilotEvents'
import { sessionFactsApi, type SessionFact } from '@/api/sessionFacts'
import {
@@ -109,6 +115,11 @@ export default function AssistantChatPage() {
// whether the active fix has a script_template_id.
const [scriptPanelOpen, setScriptPanelOpen] = useState(false)
const [scriptDecisionBusy, setScriptDecisionBusy] = useState(false)
// Phase 6: post-resolve "save as template?" queue. After Resolve succeeds
// we fetch pending drafts for this session and show the modal one at a
// time; the user accepts, rejects, or toggles "don't ask again", and we
// advance to the next pending draft.
const [templatizeQueue, setTemplatizeQueue] = useState<DraftTemplate[]>([])
const [showOverflow, setShowOverflow] = useState(false)
const toggleSidebarCollapse = () => {
const next = !sidebarCollapsed
@@ -505,6 +516,27 @@ export default function AssistantChatPage() {
toast.success('Session escalated locally (no PSA ticket linked)')
}
handleClosePreview()
// Phase 6: on a successful Resolve (either external or local), check
// for pending draft_templates rows created by the Phase 5 three-option
// dialog. Show the TemplatizePrompt modal iff:
// - the account preference hasn't opted out
// - the session has at least one pending draft
// Escalate doesn't trigger this flow — only resolution.
if (out.outcome === 'resolved' || out.outcome === 'resolved_local') {
try {
const prefs = await accountPreferencesApi.get()
if (prefs.preferences.templatize_prompt_enabled === false) return
const drafts = await draftTemplatesApi.list(true)
const forThisSession = drafts.filter(
(d) => d.source_session_id === activeChatId,
)
if (forThisSession.length > 0) setTemplatizeQueue(forThisSession)
} catch {
// Soft-fail: the Resolve itself succeeded. A missing preference
// or list fetch is not worth blocking the success toast.
}
}
} catch (err: unknown) {
console.error('[AssistantChat] confirm post failed:', err)
const status = (err as { response?: { status?: number }; response?: { data?: { detail?: string } } })?.response?.status
@@ -1431,6 +1463,16 @@ export default function AssistantChatPage() {
context="status"
/>
)}
{/* Phase 6: post-resolve "save as team template?" modal. Shown one draft
at a time; onResolved advances the queue. */}
{templatizeQueue.length > 0 && (
<TemplatizePrompt
draft={templatizeQueue[0]}
sourceTicketRef={activePsaTicketId ? `CW #${activePsaTicketId}` : null}
onResolved={() => setTemplatizeQueue((q) => q.slice(1))}
/>
)}
</div>
</>
)