feat(pilot): Phase 6 — post-resolve templatize prompt + draft accept/reject
All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
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:
@@ -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)
|
||||
|
||||
221
backend/app/api/endpoints/draft_templates.py
Normal file
221
backend/app/api/endpoints/draft_templates.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user