From 4aaf57adb561fb8959a5b4f92252f2beb5d5f304 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 22 Apr 2026 02:37:49 -0400 Subject: [PATCH] =?UTF-8?q?feat(pilot):=20Phase=206=20=E2=80=94=20post-res?= =?UTF-8?q?olve=20templatize=20prompt=20+=20draft=20accept/reject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/app/api/endpoints/accounts.py | 63 ++++ backend/app/api/endpoints/draft_templates.py | 221 +++++++++++++ backend/app/api/router.py | 2 + backend/app/schemas/draft_template.py | 68 ++++ backend/tests/test_phase6_draft_templates.py | 295 +++++++++++++++++ .../FLOWPILOT-MIGRATION.md | 20 +- frontend/src/api/draftTemplates.ts | 98 ++++++ frontend/src/components/layout/Sidebar.tsx | 14 +- .../pilot/script/TemplatizePrompt.tsx | 308 ++++++++++++++++++ frontend/src/pages/AssistantChatPage.tsx | 42 +++ 10 files changed, 1128 insertions(+), 3 deletions(-) create mode 100644 backend/app/api/endpoints/draft_templates.py create mode 100644 backend/app/schemas/draft_template.py create mode 100644 backend/tests/test_phase6_draft_templates.py create mode 100644 frontend/src/api/draftTemplates.ts create mode 100644 frontend/src/components/pilot/script/TemplatizePrompt.tsx diff --git a/backend/app/api/endpoints/accounts.py b/backend/app/api/endpoints/accounts.py index 66148952..b7910ca2 100644 --- a/backend/app/api/endpoints/accounts.py +++ b/backend/app/api/endpoints/accounts.py @@ -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) diff --git a/backend/app/api/endpoints/draft_templates.py b/backend/app/api/endpoints/draft_templates.py new file mode 100644 index 00000000..5d619c3d --- /dev/null +++ b/backend/app/api/endpoints/draft_templates.py @@ -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 diff --git a/backend/app/api/router.py b/backend/app/api/router.py index da75d9c9..c831a5d0 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/app/schemas/draft_template.py b/backend/app/schemas/draft_template.py new file mode 100644 index 00000000..a93ba8e3 --- /dev/null +++ b/backend/app/schemas/draft_template.py @@ -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"] diff --git a/backend/tests/test_phase6_draft_templates.py b/backend/tests/test_phase6_draft_templates.py new file mode 100644 index 00000000..8930f9f6 --- /dev/null +++ b/backend/tests/test_phase6_draft_templates.py @@ -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 diff --git a/docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md b/docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md index 9374cfb7..4ea082a4 100644 --- a/docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md +++ b/docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md @@ -2,8 +2,8 @@ > **Target:** Transform `/assistant` (ResolutionAssist) into the new unified `/pilot` (FlowPilot) surface. > **Audience:** Claude Code (implementation) and Codex (review) reviewed by Michael (owner). -> **Status:** Phases 0–5 implemented and verified end-to-end against the dev stack. Phase 6 next. -> **Last updated:** April 22, 2026 (Phase 5 — inline Script Generator integration — committed; live decision endpoint with Sonnet-driven TemplateExtractionService verified) +> **Status:** Phases 0–6 implemented and verified end-to-end against the dev stack. Phase 7 (polish) next. +> **Last updated:** April 22, 2026 (Phase 6 — post-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" ``` diff --git a/frontend/src/api/draftTemplates.ts b/frontend/src/api/draftTemplates.ts new file mode 100644 index 00000000..30252d4d --- /dev/null +++ b/frontend/src/api/draftTemplates.ts @@ -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 + 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 + 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 { + const r = await apiClient.get<{ drafts: DraftTemplate[] }>('/draft-templates', { + params: { pending_only: pendingOnly }, + }) + return r.data.drafts + }, + + async get(id: string): Promise { + const r = await apiClient.get(`/draft-templates/${id}`) + return r.data + }, + + async accept(id: string, data: DraftAcceptRequest): Promise { + const r = await apiClient.post( + `/draft-templates/${id}/accept`, + data, + ) + return r.data + }, + + async reject(id: string): Promise { + const r = await apiClient.post( + `/draft-templates/${id}/reject`, + ) + return r.data + }, +} + +// ── Account preferences (used by the "don't ask again" opt-out) ──────────── + +export interface AccountPreferences { + preferences: Record +} + +export const accountPreferencesApi = { + async get(): Promise { + const r = await apiClient.get('/accounts/me/preferences') + return r.data + }, + + async update(patch: Record): Promise { + const r = await apiClient.patch('/accounts/me/preferences', { + preferences: patch, + }) + return r.data + }, +} + +export default draftTemplatesApi diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index b8e2ae82..9d552882 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -44,6 +44,10 @@ export function Sidebar() { const toggleSidebarPinned = useUserPreferencesStore(s => s.toggleSidebarPinned) const [stats, setStats] = useState(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(0) const [flyoutIndex, setFlyoutIndex] = useState(null) const flyoutTimeout = useRef | null>(null) const sidebarRef = useRef(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' }, ], }, diff --git a/frontend/src/components/pilot/script/TemplatizePrompt.tsx b/frontend/src/components/pilot/script/TemplatizePrompt.tsx new file mode 100644 index 00000000..fbeb5760 --- /dev/null +++ b/frontend/src/components/pilot/script/TemplatizePrompt.tsx @@ -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 => 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(() => normalizeParams(draft.proposed_parameters)) + const [dontAskAgain, setDontAskAgain] = useState(false) + const [categories, setCategories] = useState([]) + 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 ( + + +
+ + +
+ + } + > +
+
+ +
+ This script ran as a one-off during the resolved session + {sourceTicketRef && ( + <> ({sourceTicketRef}) + )} + . Review the proposed parameters and save it to the Script Library + so the team can reuse it. +
+
+ +
+
+ + 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" + /> +
+
+ + +
+
+ +
+ +