From dcfc70b1e6ffbc1887156470a7efeddc6f7352ce Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 7 Mar 2026 00:20:32 -0500 Subject: [PATCH] feat: add AI suggestion audit trail endpoints Create/list/resolve endpoints for tracking AI-applied changes to flows. Co-Authored-By: Claude Opus 4.6 --- backend/app/api/endpoints/ai_suggestions.py | 79 +++++++++++++++++++++ backend/app/api/router.py | 2 + backend/app/schemas/ai_suggestion.py | 33 +++++++++ backend/tests/test_ai_suggestions.py | 41 +++++++++++ 4 files changed, 155 insertions(+) create mode 100644 backend/app/api/endpoints/ai_suggestions.py create mode 100644 backend/app/schemas/ai_suggestion.py create mode 100644 backend/tests/test_ai_suggestions.py diff --git a/backend/app/api/endpoints/ai_suggestions.py b/backend/app/api/endpoints/ai_suggestions.py new file mode 100644 index 00000000..e5f0919f --- /dev/null +++ b/backend/app/api/endpoints/ai_suggestions.py @@ -0,0 +1,79 @@ +"""AI Suggestion audit trail endpoints.""" +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from datetime import datetime, timezone + +from app.api.deps import get_current_active_user, get_db +from app.models.user import User +from app.models.ai_suggestion import AISuggestion +from app.schemas.ai_suggestion import ( + AISuggestionCreate, + AISuggestionResponse, + AISuggestionResolve, +) + +router = APIRouter(prefix="/ai/suggestions", tags=["ai-suggestions"]) + + +@router.get("/tree/{tree_id}", response_model=list[AISuggestionResponse]) +async def list_suggestions( + tree_id: UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """List all suggestions for a flow, filtered to current user.""" + result = await db.execute( + select(AISuggestion) + .where(AISuggestion.tree_id == tree_id, AISuggestion.user_id == current_user.id) + .order_by(AISuggestion.created_at.desc()) + ) + return result.scalars().all() + + +@router.post("", response_model=AISuggestionResponse, status_code=201) +async def create_suggestion( + data: AISuggestionCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Record a new AI suggestion.""" + suggestion = AISuggestion( + tree_id=data.tree_id, + user_id=current_user.id, + session_id=data.session_id, + action_type=data.action_type, + target_node_id=data.target_node_id, + changes_json=data.changes_json, + ) + db.add(suggestion) + await db.commit() + await db.refresh(suggestion) + return suggestion + + +@router.patch("/{suggestion_id}", response_model=AISuggestionResponse) +async def resolve_suggestion( + suggestion_id: UUID, + data: AISuggestionResolve, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Accept or dismiss a suggestion.""" + result = await db.execute( + select(AISuggestion).where( + AISuggestion.id == suggestion_id, + AISuggestion.user_id == current_user.id, + ) + ) + suggestion = result.scalar_one_or_none() + if not suggestion: + raise HTTPException(status_code=404, detail="Suggestion not found") + + suggestion.status = data.status + suggestion.resolved_at = datetime.now(timezone.utc) + await db.commit() + await db.refresh(suggestion) + return suggestion diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 503357eb..6b2d8d2a 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -13,6 +13,7 @@ from app.api.endpoints import assistant_chat from app.api.endpoints import survey from app.api.endpoints import admin_survey from app.api.endpoints import tree_transfer +from app.api.endpoints import ai_suggestions api_router = APIRouter() @@ -50,3 +51,4 @@ api_router.include_router(assistant_chat.router) api_router.include_router(survey.router) api_router.include_router(admin_survey.router) api_router.include_router(tree_transfer.router) +api_router.include_router(ai_suggestions.router) diff --git a/backend/app/schemas/ai_suggestion.py b/backend/app/schemas/ai_suggestion.py new file mode 100644 index 00000000..f1ca1426 --- /dev/null +++ b/backend/app/schemas/ai_suggestion.py @@ -0,0 +1,33 @@ +"""Schemas for AI suggestion audit trail.""" +from datetime import datetime +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel, Field + + +class AISuggestionCreate(BaseModel): + tree_id: UUID + session_id: Optional[UUID] = None + action_type: str + target_node_id: Optional[str] = None + changes_json: dict = Field(default_factory=dict) + + +class AISuggestionResponse(BaseModel): + id: UUID + tree_id: UUID + user_id: UUID + session_id: Optional[UUID] + action_type: str + target_node_id: Optional[str] + changes_json: dict + status: str + created_at: datetime + resolved_at: Optional[datetime] + + model_config = {"from_attributes": True} + + +class AISuggestionResolve(BaseModel): + status: str = Field(..., pattern="^(accepted|dismissed)$") diff --git a/backend/tests/test_ai_suggestions.py b/backend/tests/test_ai_suggestions.py new file mode 100644 index 00000000..f06b3ac4 --- /dev/null +++ b/backend/tests/test_ai_suggestions.py @@ -0,0 +1,41 @@ +"""Tests for AI suggestion endpoints.""" +import pytest + + +@pytest.mark.asyncio +async def test_create_and_list_suggestions(client, auth_headers, test_tree): + """Can create and list suggestions for a tree.""" + tree_id = test_tree["id"] + + # Create suggestion + resp = await client.post( + "/api/v1/ai/suggestions", + json={ + "tree_id": tree_id, + "action_type": "generate_branch", + "target_node_id": "some-node", + "changes_json": {"before": {}, "after": {"id": "new-node"}}, + }, + headers=auth_headers, + ) + assert resp.status_code == 201 + suggestion_id = resp.json()["id"] + assert resp.json()["status"] == "pending" + + # List suggestions + resp = await client.get( + f"/api/v1/ai/suggestions/tree/{tree_id}", + headers=auth_headers, + ) + assert resp.status_code == 200 + assert len(resp.json()) >= 1 + + # Resolve suggestion + resp = await client.patch( + f"/api/v1/ai/suggestions/{suggestion_id}", + json={"status": "accepted"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "accepted" + assert resp.json()["resolved_at"] is not None