From 4bbdce51232a801e17f265de5c9a9722ca1c45eb Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 20 Mar 2026 02:48:10 +0000 Subject: [PATCH] docs: add Search & Recall + Evidence implementation plan 10 tasks: S3 storage service, upload endpoints, RichTextInput with clipboard paste, FlowPilot integration, export evidence, structured filters, FTS, Command Palette search, semantic similar matching, similar sessions UI. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-20-search-recall-evidence-impl.md | 589 ++++++++++++++++++ 1 file changed, 589 insertions(+) create mode 100644 docs/plans/2026-03-20-search-recall-evidence-impl.md diff --git a/docs/plans/2026-03-20-search-recall-evidence-impl.md b/docs/plans/2026-03-20-search-recall-evidence-impl.md new file mode 100644 index 00000000..b22dd710 --- /dev/null +++ b/docs/plans/2026-03-20-search-recall-evidence-impl.md @@ -0,0 +1,589 @@ +# 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 `