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) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 03:42:01 +00:00
parent c3afc7a059
commit ce68fa84ca
6 changed files with 148 additions and 7 deletions

View File

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

View File

@@ -16,7 +16,7 @@ from typing import Annotated, Optional
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status 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.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -41,6 +41,7 @@ from app.schemas.ai_session import (
AISessionSummary, AISessionSummary,
AISessionDetail, AISessionDetail,
AISessionStepResponse, AISessionStepResponse,
AISessionSearchResult,
StepOptionSchema, StepOptionSchema,
) )
from app.services import flowpilot_engine from app.services import flowpilot_engine
@@ -454,6 +455,44 @@ async def link_ticket_to_session(
return detail 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 ── # ── List sessions ──
@router.get("", response_model=list[AISessionSummary]) @router.get("", response_model=list[AISessionSummary])
@@ -502,7 +541,10 @@ async def list_sessions(
query = query.where(AISession.created_at >= date_from) query = query.where(AISession.created_at >= date_from)
if date_to: if date_to:
query = query.where(AISession.created_at <= 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) result = await db.execute(query)
sessions = result.scalars().all() sessions = result.scalars().all()

View File

@@ -190,3 +190,14 @@ class AISessionDetail(AISessionSummary):
steps: list[AISessionStepResponse] = [] steps: list[AISessionStepResponse] = []
model_config = {"from_attributes": True} 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}

View File

@@ -10,6 +10,7 @@ import type {
SessionDocumentation, SessionDocumentation,
AISessionSummary, AISessionSummary,
AISessionDetail, AISessionDetail,
AISessionSearchResult,
PickupSessionRequest, PickupSessionRequest,
} from '@/types/ai-session' } from '@/types/ai-session'
@@ -114,6 +115,13 @@ export const aiSessionsApi = {
const response = await apiClient.get<AISessionSummary[]>('/ai-sessions/escalation-queue') const response = await apiClient.get<AISessionSummary[]>('/ai-sessions/escalation-queue')
return response.data return response.data
}, },
async search(q: string, limit: number = 5): Promise<AISessionSearchResult[]> {
const response = await apiClient.get<AISessionSearchResult[]>('/ai-sessions/search', {
params: { q, limit },
})
return response.data
},
} }
export default aiSessionsApi export default aiSessionsApi

View File

@@ -2,12 +2,14 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { import {
Search, Loader2, ArrowRight, FileText, Clock, Search, Loader2, ArrowRight, FileText, Clock,
Sparkles, LayoutDashboard, Tag, Plus, BookOpen, Terminal, Sparkles, LayoutDashboard, Tag, Plus, BookOpen, Terminal, Zap,
} from 'lucide-react' } from 'lucide-react'
import { treesApi } from '@/api/trees' import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions' import { sessionsApi } from '@/api/sessions'
import { aiSessionsApi } from '@/api/aiSessions'
import type { TreeListItem } from '@/types' import type { TreeListItem } from '@/types'
import type { Session } from '@/types/session' import type { Session } from '@/types/session'
import type { AISessionSearchResult } from '@/types/ai-session'
import { getTreeNavigatePath } from '@/lib/routing' import { getTreeNavigatePath } from '@/lib/routing'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { detectIntent } from '@/lib/paletteIntent' import { detectIntent } from '@/lib/paletteIntent'
@@ -19,7 +21,7 @@ interface CommandPaletteProps {
onClose: () => void 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 { interface PaletteItem {
id: string id: string
@@ -27,7 +29,7 @@ interface PaletteItem {
title: string title: string
subtitle?: string subtitle?: string
path: string path: string
icon: 'sparkles' | 'tree' | 'session' | 'page' | 'tag' | 'action' | 'recent' icon: 'sparkles' | 'tree' | 'session' | 'ai-session' | 'page' | 'tag' | 'action' | 'recent'
} }
interface Group { interface Group {
@@ -63,6 +65,7 @@ function ItemIcon({ icon, className }: { icon: PaletteItem['icon'], className?:
case 'sparkles': return <Sparkles size={16} className={cls} /> case 'sparkles': return <Sparkles size={16} className={cls} />
case 'tree': return <FileText size={16} className={cls} /> case 'tree': return <FileText size={16} className={cls} />
case 'session': return <Clock size={16} className={cls} /> case 'session': return <Clock size={16} className={cls} />
case 'ai-session': return <Zap size={16} className={cls} />
case 'page': return <LayoutDashboard size={16} className={cls} /> case 'page': return <LayoutDashboard size={16} className={cls} />
case 'tag': return <Tag size={16} className={cls} /> case 'tag': return <Tag size={16} className={cls} />
case 'action': return <Plus size={16} className={cls} /> case 'action': return <Plus size={16} className={cls} />
@@ -80,6 +83,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
const [searchFlows, setSearchFlows] = useState<TreeListItem[]>([]) const [searchFlows, setSearchFlows] = useState<TreeListItem[]>([])
const [searchSessions, setSearchSessions] = useState<Session[]>([]) const [searchSessions, setSearchSessions] = useState<Session[]>([])
const [searchAISessions, setSearchAISessions] = useState<AISessionSearchResult[]>([])
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Focus input when opened // Focus input when opened
@@ -88,6 +92,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
setQuery('') setQuery('')
setSearchFlows([]) setSearchFlows([])
setSearchSessions([]) setSearchSessions([])
setSearchAISessions([])
setSelectedIndex(0) setSelectedIndex(0)
setTimeout(() => inputRef.current?.focus(), 50) setTimeout(() => inputRef.current?.focus(), 50)
} }
@@ -109,15 +114,17 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
if (query.trim().length < 2) { if (query.trim().length < 2) {
setSearchFlows([]) setSearchFlows([])
setSearchSessions([]) setSearchSessions([])
setSearchAISessions([])
setIsSearching(false) setIsSearching(false)
return return
} }
setIsSearching(true) setIsSearching(true)
debounceRef.current = setTimeout(async () => { debounceRef.current = setTimeout(async () => {
try { try {
const [flows, sessions] = await Promise.all([ const [flows, sessions, aiSessions] = await Promise.all([
treesApi.search(query, 6), treesApi.search(query, 6),
sessionsApi.list({ size: 5 }).catch(() => [] as Session[]), sessionsApi.list({ size: 5 }).catch(() => [] as Session[]),
aiSessionsApi.search(query, 5).catch(() => [] as AISessionSearchResult[]),
]) ])
setSearchFlows(flows) setSearchFlows(flows)
// Filter sessions by tree name // Filter sessions by tree name
@@ -125,9 +132,11 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
s.tree_snapshot?.name?.toLowerCase().includes(query.toLowerCase()) s.tree_snapshot?.name?.toLowerCase().includes(query.toLowerCase())
).slice(0, 3) ).slice(0, 3)
setSearchSessions(filtered) setSearchSessions(filtered)
setSearchAISessions(aiSessions)
} catch { } catch {
setSearchFlows([]) setSearchFlows([])
setSearchSessions([]) setSearchSessions([])
setSearchAISessions([])
} finally { } finally {
setIsSearching(false) setIsSearching(false)
} }
@@ -216,6 +225,23 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
icon: 'session' as const, 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[] = [] const result: Group[] = []
if (intent === 'question') { if (intent === 'question') {
@@ -223,12 +249,14 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
result.push({ type: 'flowpilot', label: 'AI Assistant', items: [flowPilotItem] }) result.push({ type: 'flowpilot', label: 'AI Assistant', items: [flowPilotItem] })
if (flowItems.length > 0) result.push({ type: 'flows', label: 'Flows', items: flowItems }) 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 (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 (tagItems.length > 0) result.push({ type: 'tags', label: 'Tags', items: tagItems })
} else if (intent === 'page') { } else if (intent === 'page') {
// Pages first, FlowPilot at bottom // Pages first, FlowPilot at bottom
if (filteredPages.length > 0) result.push({ type: 'pages', label: 'Pages', items: filteredPages }) 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 (flowItems.length > 0) result.push({ type: 'flows', label: 'Flows', items: flowItems })
if (sessionItems.length > 0) result.push({ type: 'sessions', label: 'Sessions', items: sessionItems }) 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 (tagItems.length > 0) result.push({ type: 'tags', label: 'Tags', items: tagItems })
result.push({ type: 'flowpilot', label: 'AI Assistant', items: [flowPilotItem] }) result.push({ type: 'flowpilot', label: 'AI Assistant', items: [flowPilotItem] })
} else { } else {
@@ -236,12 +264,13 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
result.push({ type: 'flowpilot', label: 'AI Assistant', items: [flowPilotItem] }) result.push({ type: 'flowpilot', label: 'AI Assistant', items: [flowPilotItem] })
if (flowItems.length > 0) result.push({ type: 'flows', label: 'Flows', items: flowItems }) 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 (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 (tagItems.length > 0) result.push({ type: 'tags', label: 'Tags', items: tagItems })
if (filteredPages.length > 0) result.push({ type: 'pages', label: 'Pages', items: filteredPages }) if (filteredPages.length > 0) result.push({ type: 'pages', label: 'Pages', items: filteredPages })
} }
return result return result
}, [query, searchFlows, searchSessions, user]) }, [query, searchFlows, searchSessions, searchAISessions, user])
// Flatten all items for keyboard navigation // Flatten all items for keyboard navigation
const flatItems: PaletteItem[] = builtGroups.flatMap(g => g.items) const flatItems: PaletteItem[] = builtGroups.flatMap(g => g.items)

View File

@@ -141,3 +141,11 @@ export interface AISessionDetail extends AISessionSummary {
ticket_data: Record<string, unknown> | null ticket_data: Record<string, unknown> | null
steps: AISessionStepResponse[] steps: AISessionStepResponse[]
} }
export interface AISessionSearchResult {
id: string
problem_summary: string | null
problem_domain: string | null
status: string
created_at: string
}