# Search & Recall + Evidence-Rich Sessions — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add file upload with clipboard paste for evidence capture during FlowPilot sessions, and build three layers of session search (structured filters, full-text, semantic similarity). **Architecture:** Evidence uses Railway Object Storage (S3-compatible) via boto3, with a `file_uploads` table tracking metadata. Search uses PostgreSQL generated tsvector columns with GIN indexes for full-text, and Voyage AI embeddings with pgvector for semantic similarity (reusing existing RAG infrastructure). A `RichTextInput` component handles clipboard paste-to-upload UX. **Tech Stack:** FastAPI, SQLAlchemy 2.0 (async), boto3 (S3), pgvector, Voyage AI embeddings, React 19, TypeScript, Tailwind CSS v4 **Design doc:** `docs/plans/2026-03-20-search-recall-evidence-design.md` --- ## Part A: Evidence-Rich Sessions ### Task 1: S3 storage service + file_uploads model **Files:** - Create: `backend/app/services/storage_service.py` - Create: `backend/app/models/file_upload.py` - Modify: `backend/app/models/__init__.py` - Modify: `backend/app/core/config.py` - Create: migration **Step 1: Add storage config** In `backend/app/core/config.py`, add to the Settings class: ```python # Object Storage (Railway S3-compatible) STORAGE_ENDPOINT: str | None = None STORAGE_ACCESS_KEY: str | None = None STORAGE_SECRET_KEY: str | None = None STORAGE_BUCKET_NAME: str = "resolutionflow-uploads" STORAGE_REGION: str = "us-east-1" ``` **Step 2: Create file_uploads model** `backend/app/models/file_upload.py`: ```python """File upload metadata — tracks files stored in S3-compatible object storage.""" import uuid from datetime import datetime, timezone from typing import Optional from sqlalchemy import String, Integer, DateTime, ForeignKey from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.dialects.postgresql import UUID from app.core.database import Base class FileUpload(Base): __tablename__ = "file_uploads" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True ) uploaded_by: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=False ) session_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="SET NULL"), nullable=True, index=True ) filename: Mapped[str] = mapped_column(String(255), nullable=False) content_type: Mapped[str] = mapped_column(String(100), nullable=False) size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) storage_key: Mapped[str] = mapped_column(String(500), nullable=False, unique=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) ``` Register in `backend/app/models/__init__.py`. **Step 3: Create storage service** `backend/app/services/storage_service.py`: ```python """S3-compatible object storage service for file uploads.""" import logging import uuid from io import BytesIO import boto3 from botocore.config import Config as BotoConfig from botocore.exceptions import ClientError from app.core.config import settings logger = logging.getLogger(__name__) ALLOWED_IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"} ALLOWED_TEXT_TYPES = {"text/plain", "text/csv", "application/octet-stream"} ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | ALLOWED_TEXT_TYPES MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB MAX_TEXT_SIZE = 1 * 1024 * 1024 # 1MB MAX_FILES_PER_SESSION = 20 MAX_BYTES_PER_SESSION = 50 * 1024 * 1024 # 50MB PRESIGNED_URL_EXPIRY = 3600 # 1 hour def _get_client(): """Get S3 client configured for Railway Object Storage.""" if not settings.STORAGE_ENDPOINT: raise RuntimeError("Object storage not configured (STORAGE_ENDPOINT missing)") return boto3.client( "s3", endpoint_url=settings.STORAGE_ENDPOINT, aws_access_key_id=settings.STORAGE_ACCESS_KEY, aws_secret_access_key=settings.STORAGE_SECRET_KEY, region_name=settings.STORAGE_REGION, config=BotoConfig(signature_version="s3v4"), ) def validate_upload(content_type: str, size_bytes: int) -> str | None: """Validate file type and size. Returns error message or None.""" if content_type not in ALLOWED_TYPES: return f"File type {content_type} not allowed" max_size = MAX_IMAGE_SIZE if content_type in ALLOWED_IMAGE_TYPES else MAX_TEXT_SIZE if size_bytes > max_size: return f"File too large ({size_bytes} bytes, max {max_size})" return None async def upload_file( file_data: bytes, filename: str, content_type: str, account_id: str, ) -> str: """Upload file to S3, returns the storage key.""" ext = filename.rsplit(".", 1)[-1] if "." in filename else "bin" storage_key = f"uploads/{account_id}/{uuid.uuid4()}.{ext}" client = _get_client() client.upload_fileobj( BytesIO(file_data), settings.STORAGE_BUCKET_NAME, storage_key, ExtraArgs={"ContentType": content_type}, ) return storage_key def get_presigned_url(storage_key: str) -> str: """Generate a time-limited presigned URL for downloading a file.""" client = _get_client() return client.generate_presigned_url( "get_object", Params={"Bucket": settings.STORAGE_BUCKET_NAME, "Key": storage_key}, ExpiresIn=PRESIGNED_URL_EXPIRY, ) async def delete_file(storage_key: str) -> None: """Delete a file from S3.""" try: client = _get_client() client.delete_object(Bucket=settings.STORAGE_BUCKET_NAME, Key=storage_key) except ClientError: logger.warning(f"Failed to delete S3 object: {storage_key}") ``` **Step 4: Generate migration, add boto3 to requirements** ```bash pip install boto3 echo "boto3>=1.34.0" >> backend/requirements.txt cd backend && alembic revision --autogenerate -m "add file_uploads table" alembic upgrade head ``` **Step 5: Commit** ```bash git commit -m "feat(evidence): add S3 storage service and file_uploads model" ``` --- ### Task 2: Upload API endpoints **Files:** - Create: `backend/app/api/endpoints/uploads.py` - Create: `backend/app/schemas/upload.py` - Modify: `backend/app/api/router.py` - Create: `backend/tests/test_uploads.py` **Schemas** (`backend/app/schemas/upload.py`): ```python from datetime import datetime from uuid import UUID from pydantic import BaseModel class FileUploadResponse(BaseModel): id: UUID filename: str content_type: str size_bytes: int url: str # presigned URL created_at: datetime model_config = {"from_attributes": True} ``` **Endpoints** (`backend/app/api/endpoints/uploads.py`): ``` POST /uploads — Multipart upload, returns FileUploadResponse GET /uploads/{id}/url — Presigned download URL GET /uploads?session_id={id} — List uploads for a session DELETE /uploads/{id} — Delete upload + S3 object ``` Key details: - `POST /uploads` accepts `UploadFile` from FastAPI + `session_id` form field - Validates content_type and size via `storage_service.validate_upload()` - Checks per-session limits (count + total bytes) before uploading - Rate limit: `@limiter.limit("10/minute")` - All endpoints require auth via `get_current_active_user` - Delete: verify ownership (uploaded_by == current_user.id OR user is admin) **Tests:** Upload happy path, type rejection, size rejection, per-session limit, presigned URL, delete. **Commit:** ```bash git commit -m "feat(evidence): add file upload/download API endpoints" ``` --- ### Task 3: Frontend — RichTextInput component **Files:** - Create: `frontend/src/components/common/RichTextInput.tsx` - Create: `frontend/src/api/uploads.ts` - Create: `frontend/src/types/upload.ts` **Types** (`frontend/src/types/upload.ts`): ```typescript export interface FileUploadResponse { id: string filename: string content_type: string size_bytes: number url: string created_at: string } export interface PendingUpload { id: string // temp client ID file: File preview: string // object URL for thumbnail status: 'uploading' | 'done' | 'error' result?: FileUploadResponse error?: string } ``` **API** (`frontend/src/api/uploads.ts`): ```typescript import apiClient from './client' import type { FileUploadResponse } from '@/types/upload' export const uploadsApi = { async upload(file: File, sessionId?: string): Promise { const formData = new FormData() formData.append('file', file) if (sessionId) formData.append('session_id', sessionId) const response = await apiClient.post('/uploads', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }) return response.data }, async getUrl(id: string): Promise { const response = await apiClient.get<{ url: string }>(`/uploads/${id}/url`) return response.data.url }, async list(sessionId: string): Promise { const response = await apiClient.get('/uploads', { params: { session_id: sessionId } }) return response.data }, async remove(id: string): Promise { await apiClient.delete(`/uploads/${id}`) }, } ``` **RichTextInput component** (`frontend/src/components/common/RichTextInput.tsx`): Props: ```typescript interface RichTextInputProps { value: string onChange: (value: string) => void onFilesChange?: (uploads: FileUploadResponse[]) => void sessionId?: string placeholder?: string rows?: number className?: string disabled?: boolean } ``` Behavior: - Renders a `