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