# Sprint 19: Artifacts v1

> **Size**: XL | **Owner**: Backend | **Prerequisites**: Sprint 18 (Projects)
> **Phase**: 5 - Platform Evolution

---

## Open Issues & Prerequisites

> ✅ **Review Date**: 2025-12-31 — Prerequisites verified, design decisions finalized

### Blocking Issues

| ID | Issue | Status | Resolution |
|----|-------|--------|------------|
| **B1** | **Sprint 18 (Projects) partial** | ✅ Resolved | Project scoping + repositories + UI implemented |

### Design Gaps to Address

| ID | Gap | Resolution |
|----|-----|------------|
| G1 | No EmbeddingProvider abstraction | Create protocol + OpenAI/SentenceTransformers implementations |
| G2 | No text chunking infrastructure | Create TextChunker with configurable strategies |
| G3 | Artifact storage location undefined | Use same pattern as audio: `~/.noteflow/artifacts/<artifact-id>/` |

### Prerequisite Verification

| Prerequisite | Status | Notes |
|--------------|--------|-------|
| SegmentModel.embedding (1536 dims) | ✅ Verified | `models/core/meeting.py:213` |
| cosine_distance query | ✅ Verified | `repositories/segment_repo.py:148` |
| Encrypted asset storage | ✅ Verified | `infrastructure/security/crypto.py` ChunkedAssetWriter |
| Project entity | ✅ Verified | `domain/entities/project.py:141-279` |

---

## Validation Status (2025-12-30)

### NOT IMPLEMENTED

| Component | Status | Notes |
|-----------|--------|-------|
| Artifact domain entity | Not implemented | `domain/entities/artifact.py` |
| ArtifactChunk entity | Not implemented | With embedding vector |
| EmbeddingProvider protocol | Not implemented | `domain/ports/embedding.py` |
| TextChunker service | Not implemented | `infrastructure/artifacts/chunking.py` |
| Text extractors | Not implemented | PDF, DOCX, HTML, EPUB, RTF, ODT, MD, TXT |
| ArtifactModel ORM | Not implemented | `infrastructure/persistence/models/artifact.py` |
| ArtifactRepository | Not implemented | Port + SQLAlchemy implementation |
| Artifact gRPC mixin | Not implemented | Upload, list, delete, retrieve RPCs |

### PARTIALLY IMPLEMENTED

| Component | Status | Notes |
|-----------|--------|-------|
| Encrypted storage | ✅ Exists | Reuse `ChunkedAssetWriter` from audio |
| Vector search | ✅ Exists | Extend `search_semantic` pattern |
| Project scoping | ✅ Implemented | Project entity + repositories + active project flow |

**Downstream impact**: Sprint 20 (Artifacts v2 + RAG), Sprint 24 (Graph + Explore)

**Shared abstractions for Sprint 20**: This sprint defines `EmbeddingProvider` and `VectorStorePort` protocols that Sprint 20 will implement with Qdrant. The pgvector implementation here serves as the initial backend; Qdrant migration happens in Sprint 20a.

---

## Objective

Enable uploading, processing, and semantic retrieval of non-meeting documents (PDFs, DOCX, etc.) to provide RAG context for Q&A. This creates a unified knowledge base combining meeting transcripts and uploaded artifacts.

---

## Key Decisions

| Decision | Choice | Rationale |
|----------|--------|-----------|
| **Embedding Provider** | Protocol abstraction | Multiple implementations (OpenAI, SentenceTransformers) via `EmbeddingProvider` protocol |
| **Supported Formats** | Extended (8 types) | PDF, DOCX, MD, TXT, HTML, EPUB, RTF, ODT covers most enterprise documents |
| **Chunking Strategy** | Configurable | Per-artifact strategy selection: semantic, fixed-size, or paragraph-based |
| **Encryption** | Yes (at rest) | Reuse `ChunkedAssetWriter` pattern for consistent security posture |
| **Scope** | Per-workspace | Artifacts scoped to workspace, optionally linked to project |
| **Embedding Dimension** | 1536 | Match existing `EMBEDDING_DIM` constant |

---

## What Already Exists

| Asset | Location | Implication |
|-------|----------|-------------|
| `SegmentModel.embedding` Vector(1536) | `models/core/meeting.py:213` | Dimension standardized, vector infra ready |
| `search_semantic()` cosine distance | `repositories/segment_repo.py:130-170` | Retrieval pattern to replicate |
| `ChunkedAssetWriter` / `ChunkedAssetReader` | `infrastructure/security/crypto.py:151-313` | Encrypted file storage pattern |
| `AesGcmCryptoBox` envelope encryption | `infrastructure/security/crypto.py:35-148` | DEK generation and wrapping |
| `Project` entity with `workspace_id` | `domain/entities/project.py:141-279` | Scoping infrastructure |
| `ProjectSettings.rag_enabled` | `domain/entities/project.py:79` | Per-project RAG toggle |

---

## Scope

| Task | Effort | Notes |
|------|--------|-------|
| **Domain Layer** | | |
| `Artifact` entity + `ArtifactId` value object | M | Status, metadata, file info |
| `ArtifactChunk` entity with embedding | M | Chunk text, position, embedding vector |
| `ArtifactType` enum (8 file types) | S | PDF, DOCX, MD, TXT, HTML, EPUB, RTF, ODT |
| `ChunkingStrategy` enum | S | SEMANTIC, FIXED_SIZE, PARAGRAPH |
| `EmbeddingProvider` port | S | Protocol for embedding generation |
| **Infrastructure Layer** | | |
| `ArtifactModel` + `ArtifactChunkModel` ORM | M | With Vector(1536) column |
| `SqlAlchemyArtifactRepository` | M | CRUD + semantic search |
| `OpenAIEmbeddingProvider` | M | text-embedding-3-small |
| `SentenceTransformersEmbeddingProvider` | M | all-MiniLM-L6-v2 (optional) |
| Text extractors (8 file types) | L | Unified extractor interface |
| `TextChunker` with strategies | M | Semantic, fixed, paragraph chunkers |
| Encrypted artifact storage | S | Reuse `ChunkedAssetWriter` |
| **Application Layer** | | |
| `ArtifactService` | L | Upload, process, embed, retrieve |
| **API Layer** | | |
| Proto messages + RPCs (6 endpoints) | M | Upload, List, Get, Delete, Search, GetChunks |
| `ArtifactMixin` gRPC mixin | M | RPC implementations |
| **Client Layer** | | |
| Artifact upload UI | M | Drag-drop, progress, file list |
| Artifact list/search page | M | Grid view with filters |
| Alembic migration | S | artifacts + artifact_chunks tables |

**Total Effort**: XL (3-4 weeks)

---

## Domain Model

### Artifact Entity

```python
# src/noteflow/domain/entities/artifact.py

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import NewType
from uuid import UUID, uuid4

from noteflow.domain.utils.time import utc_now


ArtifactId = NewType("ArtifactId", UUID)


class ArtifactType(Enum):
    """Supported artifact file types."""

    PDF = "pdf"
    DOCX = "docx"
    MARKDOWN = "md"
    TEXT = "txt"
    HTML = "html"
    EPUB = "epub"
    RTF = "rtf"
    ODT = "odt"

    @classmethod
    def from_extension(cls, ext: str) -> ArtifactType:
        """Get artifact type from file extension."""
        ext = ext.lower().lstrip(".")
        mapping = {
            "pdf": cls.PDF,
            "docx": cls.DOCX,
            "md": cls.MARKDOWN,
            "markdown": cls.MARKDOWN,
            "txt": cls.TEXT,
            "text": cls.TEXT,
            "html": cls.HTML,
            "htm": cls.HTML,
            "epub": cls.EPUB,
            "rtf": cls.RTF,
            "odt": cls.ODT,
        }
        if ext not in mapping:
            raise ValueError(f"Unsupported file extension: {ext}")
        return mapping[ext]


class ArtifactStatus(Enum):
    """Processing status for artifacts."""

    PENDING = "pending"  # Uploaded, awaiting processing
    PROCESSING = "processing"  # Extraction/chunking/embedding in progress
    READY = "ready"  # Fully processed and searchable
    FAILED = "failed"  # Processing failed


class ChunkingStrategy(Enum):
    """Text chunking strategies."""

    SEMANTIC = "semantic"  # Sentence-based with overlap, respects paragraphs
    FIXED_SIZE = "fixed_size"  # Token-based fixed windows
    PARAGRAPH = "paragraph"  # One chunk per paragraph


@dataclass
class ChunkingConfig:
    """Configuration for text chunking."""

    strategy: ChunkingStrategy = ChunkingStrategy.SEMANTIC
    max_chunk_size: int = 512  # Max tokens per chunk
    overlap: int = 128  # Token overlap between chunks
    min_chunk_size: int = 50  # Minimum tokens to form a chunk


@dataclass
class Artifact:
    """A non-meeting document uploaded for RAG retrieval.

    Artifacts are scoped to a workspace and optionally linked to a project.
    They are processed into chunks with embeddings for semantic search.
    """

    id: ArtifactId
    workspace_id: UUID
    name: str
    artifact_type: ArtifactType
    file_size_bytes: int
    status: ArtifactStatus = ArtifactStatus.PENDING
    project_id: UUID | None = None
    description: str | None = None
    original_filename: str | None = None
    asset_path: str | None = None  # Relative path to encrypted file
    chunk_count: int = 0
    chunking_config: ChunkingConfig = field(default_factory=ChunkingConfig)
    error_message: str | None = None
    created_at: datetime = field(default_factory=utc_now)
    updated_at: datetime = field(default_factory=utc_now)
    processed_at: datetime | None = None
    created_by: UUID | None = None

    @staticmethod
    def create(
        workspace_id: UUID,
        name: str,
        artifact_type: ArtifactType,
        file_size_bytes: int,
        *,
        project_id: UUID | None = None,
        description: str | None = None,
        original_filename: str | None = None,
        chunking_config: ChunkingConfig | None = None,
        created_by: UUID | None = None,
    ) -> Artifact:
        """Create a new artifact in pending status."""
        artifact_id = ArtifactId(uuid4())
        return Artifact(
            id=artifact_id,
            workspace_id=workspace_id,
            name=name,
            artifact_type=artifact_type,
            file_size_bytes=file_size_bytes,
            project_id=project_id,
            description=description,
            original_filename=original_filename,
            asset_path=str(artifact_id),  # Default to artifact ID
            chunking_config=chunking_config or ChunkingConfig(),
            created_by=created_by,
        )

    def mark_processing(self) -> None:
        """Mark artifact as processing."""
        self.status = ArtifactStatus.PROCESSING
        self.updated_at = utc_now()

    def mark_ready(self, chunk_count: int) -> None:
        """Mark artifact as ready after successful processing."""
        self.status = ArtifactStatus.READY
        self.chunk_count = chunk_count
        self.processed_at = utc_now()
        self.updated_at = utc_now()

    def mark_failed(self, error: str) -> None:
        """Mark artifact as failed with error message."""
        self.status = ArtifactStatus.FAILED
        self.error_message = error
        self.updated_at = utc_now()

    @property
    def is_searchable(self) -> bool:
        """Check if artifact is ready for semantic search."""
        return self.status == ArtifactStatus.READY and self.chunk_count > 0
```

### ArtifactChunk Entity

```python
# src/noteflow/domain/entities/artifact.py (continued)

from typing import NewType

ArtifactChunkId = NewType("ArtifactChunkId", UUID)


@dataclass
class ArtifactChunk:
    """A chunk of text from an artifact with embedding.

    Chunks are the atomic units for semantic search. Each chunk
    contains text, position metadata, and an embedding vector.
    """

    id: ArtifactChunkId
    artifact_id: ArtifactId
    text: str
    chunk_index: int  # Position in document (0-based)
    start_char: int  # Character offset in source text
    end_char: int  # End character offset
    token_count: int
    embedding: list[float] | None = None  # 1536-dim vector
    metadata: dict[str, str] = field(default_factory=dict)  # Page number, section, etc.
    created_at: datetime = field(default_factory=utc_now)

    @staticmethod
    def create(
        artifact_id: ArtifactId,
        text: str,
        chunk_index: int,
        start_char: int,
        end_char: int,
        token_count: int,
        *,
        metadata: dict[str, str] | None = None,
    ) -> ArtifactChunk:
        """Create a new chunk without embedding (added during processing)."""
        return ArtifactChunk(
            id=ArtifactChunkId(uuid4()),
            artifact_id=artifact_id,
            text=text,
            chunk_index=chunk_index,
            start_char=start_char,
            end_char=end_char,
            token_count=token_count,
            metadata=metadata or {},
        )
```

### EmbeddingProvider Port

```python
# src/noteflow/domain/ports/embedding.py

from typing import Protocol


class EmbeddingProvider(Protocol):
    """Protocol for generating text embeddings."""

    @property
    def dimension(self) -> int:
        """Embedding vector dimension."""
        ...

    @property
    def model_name(self) -> str:
        """Name of the embedding model."""
        ...

    async def embed_text(self, text: str) -> list[float]:
        """Generate embedding for a single text.

        Args:
            text: Text to embed.

        Returns:
            Embedding vector of `dimension` floats.
        """
        ...

    async def embed_batch(self, texts: list[str]) -> list[list[float]]:
        """Generate embeddings for multiple texts.

        Args:
            texts: List of texts to embed.

        Returns:
            List of embedding vectors.
        """
        ...
```

---

## API Schema

### Proto Additions (gRPC)

```protobuf
// noteflow.proto additions

enum ArtifactType {
  ARTIFACT_TYPE_UNSPECIFIED = 0;
  ARTIFACT_TYPE_PDF = 1;
  ARTIFACT_TYPE_DOCX = 2;
  ARTIFACT_TYPE_MARKDOWN = 3;
  ARTIFACT_TYPE_TEXT = 4;
  ARTIFACT_TYPE_HTML = 5;
  ARTIFACT_TYPE_EPUB = 6;
  ARTIFACT_TYPE_RTF = 7;
  ARTIFACT_TYPE_ODT = 8;
}

enum ArtifactStatus {
  ARTIFACT_STATUS_UNSPECIFIED = 0;
  ARTIFACT_STATUS_PENDING = 1;
  ARTIFACT_STATUS_PROCESSING = 2;
  ARTIFACT_STATUS_READY = 3;
  ARTIFACT_STATUS_FAILED = 4;
}

enum ChunkingStrategy {
  CHUNKING_STRATEGY_UNSPECIFIED = 0;
  CHUNKING_STRATEGY_SEMANTIC = 1;
  CHUNKING_STRATEGY_FIXED_SIZE = 2;
  CHUNKING_STRATEGY_PARAGRAPH = 3;
}

message ChunkingConfig {
  ChunkingStrategy strategy = 1;
  int32 max_chunk_size = 2;  // Max tokens per chunk
  int32 overlap = 3;  // Token overlap
  int32 min_chunk_size = 4;  // Minimum tokens
}

message Artifact {
  string id = 1;
  string workspace_id = 2;
  string project_id = 3;  // Optional
  string name = 4;
  ArtifactType artifact_type = 5;
  ArtifactStatus status = 6;
  int64 file_size_bytes = 7;
  int32 chunk_count = 8;
  string description = 9;
  string original_filename = 10;
  string error_message = 11;
  ChunkingConfig chunking_config = 12;
  google.protobuf.Timestamp created_at = 13;
  google.protobuf.Timestamp updated_at = 14;
  google.protobuf.Timestamp processed_at = 15;
}

message ArtifactChunk {
  string id = 1;
  string artifact_id = 2;
  string text = 3;
  int32 chunk_index = 4;
  int32 start_char = 5;
  int32 end_char = 6;
  int32 token_count = 7;
  map<string, string> metadata = 8;
}

// Upload artifact (streaming for large files)
message UploadArtifactRequest {
  oneof content {
    UploadArtifactMetadata metadata = 1;
    bytes chunk = 2;
  }
}

message UploadArtifactMetadata {
  string workspace_id = 1;
  string project_id = 2;  // Optional
  string name = 3;
  string filename = 4;
  string description = 5;
  ChunkingConfig chunking_config = 6;
}

message UploadArtifactResponse {
  Artifact artifact = 1;
}

// List artifacts
message ListArtifactsRequest {
  string workspace_id = 1;
  string project_id = 2;  // Optional filter
  ArtifactStatus status = 3;  // Optional filter
  int32 limit = 4;
  int32 offset = 5;
}

message ListArtifactsResponse {
  repeated Artifact artifacts = 1;
  int32 total = 2;
}

// Get artifact
message GetArtifactRequest {
  string artifact_id = 1;
}

message GetArtifactResponse {
  Artifact artifact = 1;
}

// Delete artifact
message DeleteArtifactRequest {
  string artifact_id = 1;
}

message DeleteArtifactResponse {
  bool deleted = 1;
}

// Search artifacts (semantic)
message SearchArtifactsRequest {
  string workspace_id = 1;
  string project_id = 2;  // Optional filter
  string query = 3;
  int32 limit = 4;  // Default 10
}

message SearchResult {
  ArtifactChunk chunk = 1;
  Artifact artifact = 2;
  float similarity_score = 3;
}

message SearchArtifactsResponse {
  repeated SearchResult results = 1;
}

// Get chunks for an artifact
message GetArtifactChunksRequest {
  string artifact_id = 1;
  int32 limit = 2;
  int32 offset = 3;
}

message GetArtifactChunksResponse {
  repeated ArtifactChunk chunks = 1;
  int32 total = 2;
}

// Service additions
service NoteFlowService {
  // ... existing RPCs ...

  // Artifact management
  rpc UploadArtifact(stream UploadArtifactRequest) returns (UploadArtifactResponse);
  rpc ListArtifacts(ListArtifactsRequest) returns (ListArtifactsResponse);
  rpc GetArtifact(GetArtifactRequest) returns (GetArtifactResponse);
  rpc DeleteArtifact(DeleteArtifactRequest) returns (DeleteArtifactResponse);
  rpc SearchArtifacts(SearchArtifactsRequest) returns (SearchArtifactsResponse);
  rpc GetArtifactChunks(GetArtifactChunksRequest) returns (GetArtifactChunksResponse);
}
```

---

## Migration Strategy

### Phase 1: Schema (Week 1)

1. Create Alembic migration for `artifacts` and `artifact_chunks` tables
2. Add `project_id` FK to `artifacts` (nullable)
3. Add `embedding` Vector(1536) column to `artifact_chunks`
4. Create indexes for workspace_id, project_id, status

### Phase 2: Infrastructure (Week 2)

1. Implement text extractors for all 8 file types
2. Implement `TextChunker` with configurable strategies
3. Implement `OpenAIEmbeddingProvider`
4. Implement `SqlAlchemyArtifactRepository`

### Phase 3: Service + API (Week 3)

1. Implement `ArtifactService` with upload/process pipeline
2. Add gRPC mixin with 6 RPCs
3. Wire up to `NoteFlowServicer`

### Phase 4: Client (Week 4)

1. Implement artifact upload UI
2. Implement artifact list/search page
3. Integration testing

### Migration Risks

| Risk | Mitigation |
|------|------------|
| Large file processing timeouts | Background processing with status polling |
| Embedding API rate limits | Batch embedding with exponential backoff |
| Vector dimension mismatch | Validate dimension matches EMBEDDING_DIM on insert |

---

## Shared Types & Reuse Notes

- **Encrypted storage**: Reuse `ChunkedAssetWriter`/`ChunkedAssetReader` from `infrastructure/security/crypto.py`
- **Vector search**: Extend `search_semantic` pattern from `SegmentRepository`
- **Repository base**: Extend `_BaseRepository` from `infrastructure/persistence/repositories/_base.py`
- **ORM converter**: Add `ArtifactConverter` following `OrmConverter` pattern
- **Align with Sprint 20**: Use compatible `SearchResult` schema for RAG responses

---

## Deliverables

### Backend

**Domain Layer**:
- [ ] `src/noteflow/domain/entities/artifact.py` — Artifact, ArtifactChunk, ArtifactType, ChunkingStrategy
- [ ] `src/noteflow/domain/ports/embedding.py` — EmbeddingProvider protocol
- [ ] `src/noteflow/domain/ports/vector_store.py` — VectorStorePort protocol (shared with Sprint 20)
- [ ] `src/noteflow/domain/ports/repositories/artifact.py` — ArtifactRepository, ArtifactChunkRepository protocols

**Infrastructure Layer**:
- [ ] `src/noteflow/infrastructure/persistence/models/artifact.py` — ArtifactModel, ArtifactChunkModel
- [ ] `src/noteflow/infrastructure/persistence/repositories/artifact_repo.py` — SQLAlchemy implementations
- [ ] `src/noteflow/infrastructure/artifacts/extractors/` — PDF, DOCX, HTML, EPUB, RTF, ODT, MD, TXT extractors
- [ ] `src/noteflow/infrastructure/artifacts/chunking.py` — TextChunker with strategies
- [ ] `src/noteflow/infrastructure/artifacts/storage.py` — Encrypted artifact file storage
- [ ] `src/noteflow/infrastructure/embedding/openai_provider.py` — OpenAI embedding provider
- [ ] `src/noteflow/infrastructure/embedding/sentence_transformers_provider.py` — Local embedding provider
- [ ] `src/noteflow/infrastructure/converters/artifact_converters.py` — ORM ↔ domain converters

**Application Layer**:
- [ ] `src/noteflow/application/services/artifact_service.py` — Upload, process, embed, retrieve

**API Layer**:
- [ ] `src/noteflow/grpc/proto/noteflow.proto` — Artifact messages and RPCs
- [ ] `src/noteflow/grpc/_mixins/artifact.py` — gRPC mixin with 6 RPCs

**Migrations**:
- [ ] `src/noteflow/infrastructure/persistence/migrations/versions/xxx_add_artifacts_schema.py`

### Client

- [ ] `client/src/api/types/artifacts.ts` — TypeScript artifact types
- [ ] `client/src/hooks/use-artifacts.ts` — Artifact operations hook
- [ ] `client/src/pages/Artifacts.tsx` — Artifact list and search page
- [ ] `client/src/components/artifacts/ArtifactUpload.tsx` — Drag-drop upload component
- [ ] `client/src/components/artifacts/ArtifactCard.tsx` — Artifact display card
- [ ] `client/src-tauri/src/commands/artifacts.rs` — Tauri IPC commands

---

## Test Strategy

### Fixtures to extend or create

- `tests/conftest.py`: add `artifact_factory`, `artifact_chunk_factory`, `mock_embedding_provider`
- `tests/infrastructure/artifacts/conftest.py`: add `sample_pdf`, `sample_docx`, `sample_md`, `sample_txt`, `sample_html`, `sample_epub`, `sample_rtf`, `sample_odt`
- `tests/fixtures/artifacts/`: add small representative files for each format (< 10KB each)

### Parameterized tests

- **Extractors**: parameterize by file type (8 types)
- **Chunking**: parameterize by strategy (SEMANTIC, FIXED_SIZE, PARAGRAPH)
- **Search**: parameterize by limit, filters (workspace, project, status)
- **Upload**: parameterize by file size (small, medium, large)

### Core test cases

- **Domain**: Artifact creation validates required fields; status transitions are valid; ChunkingConfig defaults
- **Extractors**: Each extractor handles empty content, corrupted files, non-UTF8, oversized documents
- **Chunking**: Deterministic chunk ordering; overlap rules enforced; respects min/max size
- **Embedding**: Provider called once per batch; dimension validated; rate limit handling
- **Repository**: CRUD operations; semantic search returns ordered results; scope filtering works
- **Service**: End-to-end upload → process → search flow; status updates correctly; errors propagate
- **gRPC**: Streaming upload works; list pagination correct; delete removes file and chunks

---

## Quality Gates

- [ ] `pytest tests/domain/test_artifact.py` passes
- [ ] `pytest tests/infrastructure/artifacts/` passes
- [ ] `pytest tests/application/test_artifact_service.py` passes
- [ ] `pytest tests/grpc/test_artifact_mixin.py` passes
- [ ] `pytest tests/quality/` passes (23+ test smell checks)
- [ ] `ruff check src/noteflow` zero errors
- [ ] `basedpyright` zero type errors
- [ ] `npm run lint` zero frontend errors
- [ ] `npm run test` frontend tests pass
- [ ] No `# type: ignore` without justification
- [ ] All public functions have docstrings

---

## Post-Sprint

- [ ] Artifact versioning (track revisions)
- [ ] Format-specific extractors with richer metadata (PDF page numbers, DOCX styles)
- [ ] Chunk overlap tuning based on retrieval quality metrics
- [ ] Progress webhooks for long uploads
- [ ] Quota enforcement per workspace
- [ ] Integrate with Sprint 20 Q&A interface
