Files
resolutionflow/backend/app/api/endpoints/draft_templates.py
Michael Chihlas 4aaf57adb5
All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
feat(pilot): Phase 6 — post-resolve templatize prompt + draft accept/reject
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>
2026-04-22 02:37:49 -04:00

222 lines
8.4 KiB
Python

"""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