From ce68fa84caca482c9cf76e6e52b47fa3f2675354 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 20 Mar 2026 03:42:01 +0000 Subject: [PATCH] feat(search): add PostgreSQL FTS on AI sessions with Command Palette integration - Migration: add generated tsvector column + GIN index on ai_sessions (problem_summary, resolution_summary, escalation_reason, problem_domain) - Backend: wire FTS into list_sessions q param; add GET /ai-sessions/search endpoint returning AISessionSearchResult (registered before /{session_id} to avoid UUID routing conflict) - Frontend: add AISessionSearchResult type, aiSessionsApi.search() method, and Command Palette group "FlowPilot Sessions" using Zap icon navigating to /pilot/:id Co-Authored-By: Claude Opus 4.6 (1M context) --- ...d4c8_add_full_text_search_vector_to_ai_.py | 43 +++++++++++++++++ backend/app/api/endpoints/ai_sessions.py | 46 ++++++++++++++++++- backend/app/schemas/ai_session.py | 11 +++++ frontend/src/api/aiSessions.ts | 8 ++++ .../src/components/layout/CommandPalette.tsx | 39 ++++++++++++++-- frontend/src/types/ai-session.ts | 8 ++++ 6 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 backend/alembic/versions/dbf67047d4c8_add_full_text_search_vector_to_ai_.py diff --git a/backend/alembic/versions/dbf67047d4c8_add_full_text_search_vector_to_ai_.py b/backend/alembic/versions/dbf67047d4c8_add_full_text_search_vector_to_ai_.py new file mode 100644 index 00000000..9d033b5e --- /dev/null +++ b/backend/alembic/versions/dbf67047d4c8_add_full_text_search_vector_to_ai_.py @@ -0,0 +1,43 @@ +"""add full-text search vector to ai_sessions + +Revision ID: dbf67047d4c8 +Revises: 49150866ae44 +Create Date: 2026-03-20 03:36:29.910843 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'dbf67047d4c8' +down_revision: Union[str, None] = '49150866ae44' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add generated tsvector column for full-text search + # Indexes: problem_summary, resolution_summary, escalation_reason, problem_domain + op.execute(""" + ALTER TABLE ai_sessions ADD COLUMN IF NOT EXISTS search_vector tsvector + GENERATED ALWAYS AS ( + to_tsvector('english', + coalesce(problem_summary, '') || ' ' || + coalesce(resolution_summary, '') || ' ' || + coalesce(escalation_reason, '') || ' ' || + coalesce(problem_domain, '')) + ) STORED + """) + # Add GIN index for fast FTS lookups + op.execute(""" + CREATE INDEX IF NOT EXISTS idx_ai_sessions_search + ON ai_sessions USING gin(search_vector) + """) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS idx_ai_sessions_search") + op.execute("ALTER TABLE ai_sessions DROP COLUMN IF EXISTS search_vector") diff --git a/backend/app/api/endpoints/ai_sessions.py b/backend/app/api/endpoints/ai_sessions.py index 9ca8df2b..8502f926 100644 --- a/backend/app/api/endpoints/ai_sessions.py +++ b/backend/app/api/endpoints/ai_sessions.py @@ -16,7 +16,7 @@ from typing import Annotated, Optional from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Request, status -from sqlalchemy import or_, select, func +from sqlalchemy import or_, select, func, text from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -41,6 +41,7 @@ from app.schemas.ai_session import ( AISessionSummary, AISessionDetail, AISessionStepResponse, + AISessionSearchResult, StepOptionSchema, ) from app.services import flowpilot_engine @@ -454,6 +455,44 @@ async def link_ticket_to_session( return detail +# ── Search sessions (Command Palette) ── + +@router.get("/search", response_model=list[AISessionSearchResult]) +@limiter.limit("30/minute") +async def search_sessions( + request: Request, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + q: str = Query(..., min_length=2, max_length=200), + limit: int = Query(5, ge=1, le=20), +): + """Search AI sessions by content using full-text search. Used by Command Palette.""" + result = await db.execute( + select(AISession) + .where( + or_( + AISession.user_id == current_user.id, + AISession.account_id == current_user.account_id, + ), + text("ai_sessions.search_vector @@ plainto_tsquery('english', :q)"), + ) + .params(q=q) + .order_by(AISession.created_at.desc()) + .limit(limit) + ) + sessions = result.scalars().all() + return [ + AISessionSearchResult( + id=s.id, + problem_summary=s.problem_summary, + problem_domain=s.problem_domain, + status=s.status, + created_at=s.created_at, + ) + for s in sessions + ] + + # ── List sessions ── @router.get("", response_model=list[AISessionSummary]) @@ -502,7 +541,10 @@ async def list_sessions( query = query.where(AISession.created_at >= date_from) if date_to: query = query.where(AISession.created_at <= date_to) - # TODO: Full-text search via q param — see Task 7 + if q: + query = query.where( + text("ai_sessions.search_vector @@ plainto_tsquery('english', :q)") + ).params(q=q) result = await db.execute(query) sessions = result.scalars().all() diff --git a/backend/app/schemas/ai_session.py b/backend/app/schemas/ai_session.py index 4eb78f53..ff14b700 100644 --- a/backend/app/schemas/ai_session.py +++ b/backend/app/schemas/ai_session.py @@ -190,3 +190,14 @@ class AISessionDetail(AISessionSummary): steps: list[AISessionStepResponse] = [] model_config = {"from_attributes": True} + + +class AISessionSearchResult(BaseModel): + """Lightweight session result for Command Palette / autocomplete.""" + id: UUID + problem_summary: str | None = None + problem_domain: str | None = None + status: str + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/frontend/src/api/aiSessions.ts b/frontend/src/api/aiSessions.ts index e102682b..b5590f03 100644 --- a/frontend/src/api/aiSessions.ts +++ b/frontend/src/api/aiSessions.ts @@ -10,6 +10,7 @@ import type { SessionDocumentation, AISessionSummary, AISessionDetail, + AISessionSearchResult, PickupSessionRequest, } from '@/types/ai-session' @@ -114,6 +115,13 @@ export const aiSessionsApi = { const response = await apiClient.get('/ai-sessions/escalation-queue') return response.data }, + + async search(q: string, limit: number = 5): Promise { + const response = await apiClient.get('/ai-sessions/search', { + params: { q, limit }, + }) + return response.data + }, } export default aiSessionsApi diff --git a/frontend/src/components/layout/CommandPalette.tsx b/frontend/src/components/layout/CommandPalette.tsx index b995480f..fb1cd112 100644 --- a/frontend/src/components/layout/CommandPalette.tsx +++ b/frontend/src/components/layout/CommandPalette.tsx @@ -2,12 +2,14 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useNavigate } from 'react-router-dom' import { Search, Loader2, ArrowRight, FileText, Clock, - Sparkles, LayoutDashboard, Tag, Plus, BookOpen, Terminal, + Sparkles, LayoutDashboard, Tag, Plus, BookOpen, Terminal, Zap, } from 'lucide-react' import { treesApi } from '@/api/trees' import { sessionsApi } from '@/api/sessions' +import { aiSessionsApi } from '@/api/aiSessions' import type { TreeListItem } from '@/types' import type { Session } from '@/types/session' +import type { AISessionSearchResult } from '@/types/ai-session' import { getTreeNavigatePath } from '@/lib/routing' import { cn } from '@/lib/utils' import { detectIntent } from '@/lib/paletteIntent' @@ -19,7 +21,7 @@ interface CommandPaletteProps { onClose: () => void } -type GroupType = 'flowpilot' | 'pages' | 'flows' | 'sessions' | 'tags' | 'quick-actions' | 'recent-flows' +type GroupType = 'flowpilot' | 'pages' | 'flows' | 'sessions' | 'ai-sessions' | 'tags' | 'quick-actions' | 'recent-flows' interface PaletteItem { id: string @@ -27,7 +29,7 @@ interface PaletteItem { title: string subtitle?: string path: string - icon: 'sparkles' | 'tree' | 'session' | 'page' | 'tag' | 'action' | 'recent' + icon: 'sparkles' | 'tree' | 'session' | 'ai-session' | 'page' | 'tag' | 'action' | 'recent' } interface Group { @@ -63,6 +65,7 @@ function ItemIcon({ icon, className }: { icon: PaletteItem['icon'], className?: case 'sparkles': return case 'tree': return case 'session': return + case 'ai-session': return case 'page': return case 'tag': return case 'action': return @@ -80,6 +83,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) { const [selectedIndex, setSelectedIndex] = useState(0) const [searchFlows, setSearchFlows] = useState([]) const [searchSessions, setSearchSessions] = useState([]) + const [searchAISessions, setSearchAISessions] = useState([]) const debounceRef = useRef | null>(null) // Focus input when opened @@ -88,6 +92,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) { setQuery('') setSearchFlows([]) setSearchSessions([]) + setSearchAISessions([]) setSelectedIndex(0) setTimeout(() => inputRef.current?.focus(), 50) } @@ -109,15 +114,17 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) { if (query.trim().length < 2) { setSearchFlows([]) setSearchSessions([]) + setSearchAISessions([]) setIsSearching(false) return } setIsSearching(true) debounceRef.current = setTimeout(async () => { try { - const [flows, sessions] = await Promise.all([ + const [flows, sessions, aiSessions] = await Promise.all([ treesApi.search(query, 6), sessionsApi.list({ size: 5 }).catch(() => [] as Session[]), + aiSessionsApi.search(query, 5).catch(() => [] as AISessionSearchResult[]), ]) setSearchFlows(flows) // Filter sessions by tree name @@ -125,9 +132,11 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) { s.tree_snapshot?.name?.toLowerCase().includes(query.toLowerCase()) ).slice(0, 3) setSearchSessions(filtered) + setSearchAISessions(aiSessions) } catch { setSearchFlows([]) setSearchSessions([]) + setSearchAISessions([]) } finally { setIsSearching(false) } @@ -216,6 +225,23 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) { icon: 'session' as const, })) + // Build AI session items + const aiSessionItems: PaletteItem[] = searchAISessions.map(s => { + const title = s.problem_summary + ? s.problem_summary.slice(0, 60) + (s.problem_summary.length > 60 ? '…' : '') + : 'FlowPilot Session' + const statusLabel = s.status === 'resolved' ? 'Resolved' : s.status === 'escalated' ? 'Escalated' : 'Active' + const subtitle = [s.problem_domain, statusLabel].filter(Boolean).join(' · ') + return { + id: `ai-session-${s.id}`, + group: 'ai-sessions' as GroupType, + title, + subtitle: subtitle || undefined, + path: `/pilot/${s.id}`, + icon: 'ai-session' as const, + } + }) + const result: Group[] = [] if (intent === 'question') { @@ -223,12 +249,14 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) { result.push({ type: 'flowpilot', label: 'AI Assistant', items: [flowPilotItem] }) if (flowItems.length > 0) result.push({ type: 'flows', label: 'Flows', items: flowItems }) if (sessionItems.length > 0) result.push({ type: 'sessions', label: 'Sessions', items: sessionItems }) + if (aiSessionItems.length > 0) result.push({ type: 'ai-sessions', label: 'FlowPilot Sessions', items: aiSessionItems }) if (tagItems.length > 0) result.push({ type: 'tags', label: 'Tags', items: tagItems }) } else if (intent === 'page') { // Pages first, FlowPilot at bottom if (filteredPages.length > 0) result.push({ type: 'pages', label: 'Pages', items: filteredPages }) if (flowItems.length > 0) result.push({ type: 'flows', label: 'Flows', items: flowItems }) if (sessionItems.length > 0) result.push({ type: 'sessions', label: 'Sessions', items: sessionItems }) + if (aiSessionItems.length > 0) result.push({ type: 'ai-sessions', label: 'FlowPilot Sessions', items: aiSessionItems }) if (tagItems.length > 0) result.push({ type: 'tags', label: 'Tags', items: tagItems }) result.push({ type: 'flowpilot', label: 'AI Assistant', items: [flowPilotItem] }) } else { @@ -236,12 +264,13 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) { result.push({ type: 'flowpilot', label: 'AI Assistant', items: [flowPilotItem] }) if (flowItems.length > 0) result.push({ type: 'flows', label: 'Flows', items: flowItems }) if (sessionItems.length > 0) result.push({ type: 'sessions', label: 'Sessions', items: sessionItems }) + if (aiSessionItems.length > 0) result.push({ type: 'ai-sessions', label: 'FlowPilot Sessions', items: aiSessionItems }) if (tagItems.length > 0) result.push({ type: 'tags', label: 'Tags', items: tagItems }) if (filteredPages.length > 0) result.push({ type: 'pages', label: 'Pages', items: filteredPages }) } return result - }, [query, searchFlows, searchSessions, user]) + }, [query, searchFlows, searchSessions, searchAISessions, user]) // Flatten all items for keyboard navigation const flatItems: PaletteItem[] = builtGroups.flatMap(g => g.items) diff --git a/frontend/src/types/ai-session.ts b/frontend/src/types/ai-session.ts index 21ae8020..6a7ed3c1 100644 --- a/frontend/src/types/ai-session.ts +++ b/frontend/src/types/ai-session.ts @@ -141,3 +141,11 @@ export interface AISessionDetail extends AISessionSummary { ticket_data: Record | null steps: AISessionStepResponse[] } + +export interface AISessionSearchResult { + id: string + problem_summary: string | null + problem_domain: string | null + status: string + created_at: string +}