# Sprint 1: AI Templates Pass-Through

> **Priority**: 1 | **Owner**: Both (Backend + Frontend) | **Complexity**: Low
> **Prerequisite**: Sprint 0 (Proto & Schema Foundation)

---

## Validation Status (2025-12-27)

### ✅ 100% COMPLETE

| Component | Location | Status |
|-----------|----------|--------|
| `SummarizationRequest.style_prompt` | `domain/summarization/ports.py:26` | ✅ Field exists |
| `build_style_prompt()` | `infrastructure/summarization/_parsing.py:67-100` | ✅ Tone/verbosity handling |
| `_TONE_INSTRUCTIONS` | `_parsing.py` | ✅ professional, casual, technical, friendly |
| `_VERBOSITY_INSTRUCTIONS` | `_parsing.py` | ✅ minimal, balanced, detailed, comprehensive |
| Cloud provider integration | `cloud_provider.py:199-200` | ✅ Uses `style_prompt` |
| Ollama provider integration | `ollama_provider.py:163-164` | ✅ Uses `style_prompt` |
| gRPC mixin | `_mixins/summarization.py:49-51` | ✅ Calls `build_style_prompt()` |
| SummarizationService | `application/services/summarization_service.py:175` | ✅ Accepts `style_prompt` param |
| Feature flag | `config/settings.py` | ✅ `templates_enabled` |
| Proto `SummarizationOptions` | `noteflow.proto:312-322` | ✅ Message with tone/format/verbosity |
| Rust command options | `commands/summary.rs:57-62` | ✅ Accepts `options: Option<SummarizationOptions>` |
| TypeScript adapter | `tauri-adapter.ts:126-132` | ✅ Reads `ai_template` from prefs, passes to Rust |
| Frontend template UI | `Settings.tsx` | ✅ AI template controls saved to preferences |

---

## Objective

Enable user-configured summarization style preferences (tone, format, verbosity) to flow from frontend settings through gRPC to the LLM prompt builder, affecting the generated summary style.

---

## Current State Analysis

### What Exists (Post-Sprint 0)

| Component | Location | Status |
|-----------|----------|--------|
| Frontend UI | `client/src/pages/Settings.tsx` | AI template controls saved to `ai_template` in preferences |
| Frontend Types | `client/src/lib/preferences.ts` | `AITemplate` type with tone/format/verbosity fields |
| gRPC Proto | `src/noteflow/grpc/proto/noteflow.proto:301-320` | `SummarizationOptions` message and `GenerateSummaryRequest.options` field ✅ |
| Domain Request | `src/noteflow/domain/summarization/ports.py:15-43` | `SummarizationRequest` frozen dataclass - **missing `style_prompt`** |
| Summarization Service | `src/noteflow/application/services/summarization_service.py:168` | `summarize()` has no template params |
| Prompt Builder | `src/noteflow/infrastructure/summarization/_parsing.py` | Only `build_transcript_prompt()`, no style builder |
| CloudSummarizer | `src/noteflow/infrastructure/summarization/cloud_provider.py` | Uses `SYSTEM_PROMPT` directly - **no style injection** |
| OllamaSummarizer | `src/noteflow/infrastructure/summarization/ollama_provider.py` | Uses `SYSTEM_PROMPT` directly - **no style injection** |
| Rust Command | `client/src-tauri/src/commands/summary.rs` | `generate_summary()` lacks options param |
| TypeScript Adapter | `client/src/api/tauri-adapter.ts` | `generateSummary()` doesn't read/pass preferences |

### Gap

The proto schema exists (Sprint 0), but the data flow is incomplete:

```
Frontend prefs ──✗──▶ Tauri command ──✗──▶ gRPC request ──✗──▶ Service ──✗──▶ Providers
     ↓                                                                            ↓
  ai_template                                                              SYSTEM_PROMPT
  (stored)                                                                  (unchanged)
```

---

## Target/Affected Code

### Files to Modify

| File | Change Type | Lines Est. |
|------|-------------|------------|
| `src/noteflow/infrastructure/summarization/_parsing.py` | Add `build_style_prompt()` | +45 |
| `src/noteflow/domain/summarization/ports.py` | Add `style_prompt` field to `SummarizationRequest` | +3 |
| `src/noteflow/infrastructure/summarization/cloud_provider.py` | Prepend style to system prompt | +10 |
| `src/noteflow/infrastructure/summarization/ollama_provider.py` | Prepend style to system prompt | +10 |
| `src/noteflow/application/services/summarization_service.py` | Accept and pass `style_prompt` param | +8 |
| `src/noteflow/grpc/_mixins/summarization.py` | Extract options, call builder, pass to service | +12 |
| `client/src-tauri/src/commands/summary.rs` | Accept `SummarizationOptions`, pass to gRPC | +25 |
| `client/src/api/tauri-adapter.ts` | Read `ai_template` prefs, pass to command | +15 |
| `client/src/api/types.ts` | Add `SummarizationOptions` interface | +6 |

### Files to Create

None - all changes are modifications to existing files.

---

## Implementation Tasks

### Task 1: Style Prompt Builder

**File**: `src/noteflow/infrastructure/summarization/_parsing.py`

**Add at module level** (after existing imports, before `SYSTEM_PROMPT`):

```python
import logging

_logger = logging.getLogger(__name__)

_TONE_INSTRUCTIONS: dict[str, str] = {
    "professional": "Use formal, business-appropriate language.",
    "casual": "Use conversational, approachable language.",
    "technical": "Use precise technical terminology.",
    "friendly": "Use warm, personable language.",
}

_FORMAT_INSTRUCTIONS: dict[str, str] = {
    "bullet_points": "Present information in bullet points.",
    "narrative": "Write in flowing paragraphs.",
    "structured": "Use headers and organized sections.",
    "concise": "Be extremely brief and to the point.",
}

_VERBOSITY_INSTRUCTIONS: dict[str, str] = {
    "minimal": "Provide only essential information.",
    "balanced": "Include moderate detail.",
    "detailed": "Include comprehensive information.",
    "comprehensive": "Include all relevant details and context.",
}


def build_style_prompt(
    tone: str | None = None,
    format_style: str | None = None,
    verbosity: str | None = None,
) -> str:
    """Build style instruction string from template preferences.

    Args:
        tone: Desired tone (professional, casual, technical, friendly).
        format_style: Desired format (bullet_points, narrative, structured, concise).
        verbosity: Desired verbosity (minimal, balanced, detailed, comprehensive).

    Returns:
        Style instruction string to prepend to system prompt, or empty string.
    """
    parts: list[str] = []

    if tone:
        if tone in _TONE_INSTRUCTIONS:
            parts.append(_TONE_INSTRUCTIONS[tone])
        else:
            _logger.debug("Unknown tone value ignored: %s", tone)

    if format_style:
        if format_style in _FORMAT_INSTRUCTIONS:
            parts.append(_FORMAT_INSTRUCTIONS[format_style])
        else:
            _logger.debug("Unknown format value ignored: %s", format_style)

    if verbosity:
        if verbosity in _VERBOSITY_INSTRUCTIONS:
            parts.append(_VERBOSITY_INSTRUCTIONS[verbosity])
        else:
            _logger.debug("Unknown verbosity value ignored: %s", verbosity)

    result = " ".join(parts)
    if result:
        _logger.debug("Built style prompt: %s", result)
    return result
```

**Note**: Uses primitive string parameters instead of proto message to keep infrastructure layer decoupled from gRPC.

---

### Task 2: Domain Request Update

**File**: `src/noteflow/domain/summarization/ports.py`

**Modify** `SummarizationRequest` dataclass:

```python
@dataclass(frozen=True)
class SummarizationRequest:
    """Request for summarization."""

    meeting_id: MeetingId
    segments: Sequence[Segment]
    max_key_points: int = 5
    max_action_items: int = 10
    style_prompt: str | None = None  # NEW FIELD
```

---

### Task 3: Provider Updates

Both providers must prepend `style_prompt` to `SYSTEM_PROMPT`.

#### 3a: CloudSummarizer

**File**: `src/noteflow/infrastructure/summarization/cloud_provider.py`

**Modify** `summarize()` method to build effective system prompt:

```python
async def summarize(self, request: SummarizationRequest) -> SummarizationResult:
    """Generate summary using cloud LLM provider."""
    # Build effective system prompt with style prefix
    effective_system_prompt = (
        f"{request.style_prompt}\n\n{SYSTEM_PROMPT}"
        if request.style_prompt
        else SYSTEM_PROMPT
    )

    # ... rest of method uses effective_system_prompt instead of SYSTEM_PROMPT
```

**Update both** `_call_openai()` and `_call_anthropic()` internal methods to accept and use `effective_system_prompt` parameter.

#### 3b: OllamaSummarizer

**File**: `src/noteflow/infrastructure/summarization/ollama_provider.py`

**Modify** `summarize()` method similarly:

```python
async def summarize(self, request: SummarizationRequest) -> SummarizationResult:
    """Generate summary using Ollama."""
    effective_system_prompt = (
        f"{request.style_prompt}\n\n{SYSTEM_PROMPT}"
        if request.style_prompt
        else SYSTEM_PROMPT
    )

    # Use effective_system_prompt in the messages list
    response = await asyncio.to_thread(
        client.chat,
        model=self._model,
        messages=[
            {"role": "system", "content": effective_system_prompt},  # Changed
            {"role": "user", "content": user_prompt},
        ],
        options={"temperature": 0.3},
        format="json",
    )
```

---

### Task 4: Service Layer Update

**File**: `src/noteflow/application/services/summarization_service.py`

**Modify** `summarize()` signature and request building:

```python
async def summarize(
    self,
    meeting_id: MeetingId,
    segments: Sequence[Segment],
    mode: SummarizationMode | None = None,
    max_key_points: int | None = None,
    max_action_items: int | None = None,
    style_prompt: str | None = None,  # NEW PARAMETER
) -> SummarizationServiceResult:
    """Generate meeting summary.

    Args:
        meeting_id: Meeting identifier.
        segments: Transcript segments to summarize.
        mode: Summarization mode (local/cloud). Defaults to settings.
        max_key_points: Maximum key points. Defaults to settings.
        max_action_items: Maximum action items. Defaults to settings.
        style_prompt: Optional style instruction to prepend to system prompt.

    Returns:
        Summarization result with summary and metadata.
    """
    # ... existing provider selection logic ...

    request = SummarizationRequest(
        meeting_id=meeting_id,
        segments=segments,
        max_key_points=max_key_points or self.settings.max_key_points,
        max_action_items=max_action_items or self.settings.max_action_items,
        style_prompt=style_prompt,  # NEW FIELD
    )

    # ... rest of method unchanged ...
```

---

### Task 5: gRPC Mixin Update

**File**: `src/noteflow/grpc/_mixins/summarization.py`

**Add import at top of file**:

```python
from noteflow.infrastructure.summarization._parsing import build_style_prompt
```

**Modify** `GenerateSummary` method:

```python
async def GenerateSummary(
    self: ServicerHost,
    request: noteflow_pb2.GenerateSummaryRequest,
    context: grpc.aio.ServicerContext,
) -> noteflow_pb2.Summary:
    """Generate AI summary for meeting."""
    meeting_id = self._parse_meeting_id(request.meeting_id)

    # Build style prompt from proto options
    style_prompt: str | None = None
    if request.HasField("options"):
        style_prompt = build_style_prompt(
            tone=request.options.tone or None,
            format_style=request.options.format or None,
            verbosity=request.options.verbosity or None,
        )

    # ... existing meeting fetch logic ...

    result = await self._summarization_service.summarize(
        meeting_id=meeting_id,
        segments=segments,
        style_prompt=style_prompt,  # Pass style prompt
    )

    # ... rest of method unchanged ...
```

---

### Task 6: Rust Command Update

**File**: `client/src-tauri/src/commands/summary.rs`

**Add struct definition**:

```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SummarizationOptions {
    pub tone: Option<String>,
    pub format: Option<String>,
    pub verbosity: Option<String>,
}
```

**Modify** command signature:

```rust
#[tauri::command]
pub async fn generate_summary(
    state: State<'_, Arc<AppState>>,
    app: AppHandle,
    meeting_id: String,
    force_regenerate: Option<bool>,
    options: Option<SummarizationOptions>,  // NEW PARAMETER
) -> Result<Summary> {
    let mut client = state.grpc_client.lock().await;

    // Convert to proto options
    let proto_options = options.map(|o| proto::SummarizationOptions {
        tone: o.tone.unwrap_or_default(),
        format: o.format.unwrap_or_default(),
        verbosity: o.verbosity.unwrap_or_default(),
    });

    let request = proto::GenerateSummaryRequest {
        meeting_id: meeting_id.clone(),
        force_regenerate: force_regenerate.unwrap_or(false),
        options: proto_options,
    };

    // ... rest of progress polling and gRPC call ...
}
```

---

### Task 7: TypeScript Updates

#### 7a: Types

**File**: `client/src/api/types.ts`

**Add interface**:

```typescript
export interface SummarizationOptions {
  tone?: 'professional' | 'casual' | 'technical' | 'friendly';
  format?: 'bullet_points' | 'narrative' | 'structured' | 'concise';
  verbosity?: 'minimal' | 'balanced' | 'detailed' | 'comprehensive';
}
```

#### 7b: Adapter

**File**: `client/src/api/tauri-adapter.ts`

**Modify** `generateSummary`:

```typescript
import { getPreferences } from '@/lib/preferences';
import type { SummarizationOptions } from './types';

async generateSummary(
  meetingId: string,
  forceRegenerate?: boolean,
): Promise<Summary> {
  // Read AI template from preferences
  const prefs = await getPreferences();
  const template = prefs.ai_template;

  // Build options from template if present
  const options: SummarizationOptions | undefined = template
    ? {
        tone: template.tone,
        format: template.format,
        verbosity: template.verbosity,
      }
    : undefined;

  return invoke<Summary>(TauriCommands.GENERATE_SUMMARY, {
    meeting_id: meetingId,
    force_regenerate: forceRegenerate ?? false,
    options,
  });
}
```

---

## Test Plan

### Unit Tests: Style Prompt Builder

**File**: `tests/infrastructure/summarization/test_parsing.py`

```python
import pytest

from noteflow.infrastructure.summarization._parsing import build_style_prompt


class TestBuildStylePrompt:
    """Tests for build_style_prompt function."""

    @pytest.mark.parametrize(
        ("tone", "expected_fragment"),
        [
            ("professional", "formal, business-appropriate"),
            ("casual", "conversational, approachable"),
            ("technical", "precise technical terminology"),
            ("friendly", "warm, personable"),
        ],
    )
    def test_tone_produces_expected_instruction(
        self,
        tone: str,
        expected_fragment: str,
    ) -> None:
        """Each tone value produces its corresponding instruction."""
        result = build_style_prompt(tone=tone)

        assert expected_fragment in result

    @pytest.mark.parametrize(
        ("format_style", "expected_fragment"),
        [
            ("bullet_points", "bullet points"),
            ("narrative", "flowing paragraphs"),
            ("structured", "headers and organized"),
            ("concise", "extremely brief"),
        ],
    )
    def test_format_produces_expected_instruction(
        self,
        format_style: str,
        expected_fragment: str,
    ) -> None:
        """Each format value produces its corresponding instruction."""
        result = build_style_prompt(format_style=format_style)

        assert expected_fragment in result

    @pytest.mark.parametrize(
        ("verbosity", "expected_fragment"),
        [
            ("minimal", "only essential"),
            ("balanced", "moderate detail"),
            ("detailed", "comprehensive information"),
            ("comprehensive", "all relevant details"),
        ],
    )
    def test_verbosity_produces_expected_instruction(
        self,
        verbosity: str,
        expected_fragment: str,
    ) -> None:
        """Each verbosity value produces its corresponding instruction."""
        result = build_style_prompt(verbosity=verbosity)

        assert expected_fragment in result

    def test_combines_all_options(self) -> None:
        """All three options combine into single prompt."""
        result = build_style_prompt(
            tone="professional",
            format_style="bullet_points",
            verbosity="detailed",
        )

        assert "formal" in result
        assert "bullet points" in result
        assert "comprehensive" in result

    def test_no_options_returns_empty_string(self) -> None:
        """No options produces empty string."""
        result = build_style_prompt()

        assert result == ""

    def test_none_values_returns_empty_string(self) -> None:
        """Explicit None values produce empty string."""
        result = build_style_prompt(tone=None, format_style=None, verbosity=None)

        assert result == ""

    def test_unknown_tone_ignored(self, caplog: pytest.LogCaptureFixture) -> None:
        """Unknown tone value is ignored with debug log."""
        result = build_style_prompt(tone="unknown_tone")

        assert result == ""
        assert "Unknown tone value ignored" in caplog.text

    def test_unknown_format_ignored(self, caplog: pytest.LogCaptureFixture) -> None:
        """Unknown format value is ignored with debug log."""
        result = build_style_prompt(format_style="unknown_format")

        assert result == ""
        assert "Unknown format value ignored" in caplog.text

    def test_partial_valid_options(self) -> None:
        """Valid options used even when some are invalid."""
        result = build_style_prompt(
            tone="professional",
            format_style="invalid",
            verbosity="minimal",
        )

        assert "formal" in result
        assert "essential" in result
        assert "invalid" not in result
```

### Unit Tests: Provider Style Injection

**File**: `tests/infrastructure/summarization/test_cloud_provider.py`

**Add to existing test class**:

```python
class TestCloudSummarizerStylePrompt:
    """Tests for style prompt injection."""

    def test_style_prompt_prepended_to_system_prompt(
        self,
        mock_openai: None,
        meeting_id: MeetingId,
    ) -> None:
        """Style prompt is prepended to system prompt in LLM call."""
        captured_messages: list[dict] = []

        def capture_create(**kwargs):
            captured_messages.extend(kwargs.get("messages", []))
            return types.SimpleNamespace(
                choices=[...],  # valid response
            )

        # Inject capturing mock
        summarizer = CloudSummarizer(backend=CloudBackend.OPENAI)
        segments = [_segment(0, "Test content", 0.0, 1.0)]
        request = SummarizationRequest(
            meeting_id=meeting_id,
            segments=segments,
            style_prompt="Use formal, business-appropriate language.",
        )

        asyncio.run(summarizer.summarize(request))

        system_message = next(m for m in captured_messages if m["role"] == "system")
        assert system_message["content"].startswith("Use formal")
        assert "meeting summarization assistant" in system_message["content"]

    def test_no_style_prompt_uses_default_system_prompt(
        self,
        mock_openai: None,
        meeting_id: MeetingId,
    ) -> None:
        """No style prompt uses unmodified SYSTEM_PROMPT."""
        captured_messages: list[dict] = []
        # ... similar capture pattern ...

        request = SummarizationRequest(
            meeting_id=meeting_id,
            segments=segments,
            style_prompt=None,
        )

        asyncio.run(summarizer.summarize(request))

        system_message = next(m for m in captured_messages if m["role"] == "system")
        assert system_message["content"] == SYSTEM_PROMPT
```

### Unit Tests: Service Layer

**File**: `tests/application/test_summarization_service.py`

**Add to existing test class**:

```python
class TestSummarizationServiceStylePrompt:
    """Tests for style prompt pass-through."""

    async def test_style_prompt_passed_to_provider(
        self,
        meeting_id: MeetingId,
    ) -> None:
        """Style prompt is passed through to provider."""
        captured_request: SummarizationRequest | None = None

        class CapturingProvider:
            provider_name = "capturing"
            is_available = True
            requires_cloud_consent = False

            async def summarize(self, request: SummarizationRequest) -> SummarizationResult:
                nonlocal captured_request
                captured_request = request
                return SummarizationResult(...)

        service = SummarizationService(settings=SummarizationServiceSettings())
        service.register_provider(SummarizationMode.LOCAL, CapturingProvider())

        await service.summarize(
            meeting_id=meeting_id,
            segments=[],
            style_prompt="Use technical terminology.",
        )

        assert captured_request is not None
        assert captured_request.style_prompt == "Use technical terminology."

    async def test_none_style_prompt_passed_as_none(
        self,
        meeting_id: MeetingId,
    ) -> None:
        """None style prompt is passed through as None."""
        captured_request: SummarizationRequest | None = None
        # ... similar pattern ...

        await service.summarize(
            meeting_id=meeting_id,
            segments=[],
            style_prompt=None,
        )

        assert captured_request is not None
        assert captured_request.style_prompt is None
```

### Integration Tests

**File**: `tests/integration/test_summarization_templates.py`

```python
"""Integration tests for AI template pass-through."""

import pytest

from noteflow.grpc.proto import noteflow_pb2
from noteflow.infrastructure.summarization._parsing import build_style_prompt


class TestSummarizationTemplateIntegration:
    """Integration tests for template options flow."""

    @pytest.mark.integration
    async def test_grpc_options_reach_style_builder(self) -> None:
        """Proto SummarizationOptions correctly converted to style prompt."""
        options = noteflow_pb2.SummarizationOptions(
            tone="professional",
            format="bullet_points",
            verbosity="detailed",
        )

        style_prompt = build_style_prompt(
            tone=options.tone or None,
            format_style=options.format or None,
            verbosity=options.verbosity or None,
        )

        assert "formal" in style_prompt
        assert "bullet points" in style_prompt
        assert "comprehensive" in style_prompt

    @pytest.mark.integration
    async def test_empty_options_produce_empty_style(self) -> None:
        """Empty proto options produce empty style prompt."""
        options = noteflow_pb2.SummarizationOptions()

        style_prompt = build_style_prompt(
            tone=options.tone or None,
            format_style=options.format or None,
            verbosity=options.verbosity or None,
        )

        assert style_prompt == ""
```

### Frontend Tests

**File**: `client/src/api/tauri-adapter.test.ts`

```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { invoke } from '@tauri-apps/api/core';

vi.mock('@tauri-apps/api/core');
vi.mock('@/lib/preferences');

describe('generateSummary', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('passes template options from preferences', async () => {
    const mockPrefs = {
      ai_template: {
        tone: 'professional',
        format: 'bullet_points',
        verbosity: 'detailed',
      },
    };
    vi.mocked(getPreferences).mockResolvedValue(mockPrefs);
    vi.mocked(invoke).mockResolvedValue({ executive_summary: 'Test' });

    await adapter.generateSummary('meeting-123');

    expect(invoke).toHaveBeenCalledWith(
      'generate_summary',
      expect.objectContaining({
        options: {
          tone: 'professional',
          format: 'bullet_points',
          verbosity: 'detailed',
        },
      }),
    );
  });

  it('omits options when no template configured', async () => {
    vi.mocked(getPreferences).mockResolvedValue({});
    vi.mocked(invoke).mockResolvedValue({ executive_summary: 'Test' });

    await adapter.generateSummary('meeting-123');

    expect(invoke).toHaveBeenCalledWith(
      'generate_summary',
      expect.objectContaining({
        options: undefined,
      }),
    );
  });

  it('respects forceRegenerate parameter', async () => {
    vi.mocked(getPreferences).mockResolvedValue({});
    vi.mocked(invoke).mockResolvedValue({ executive_summary: 'Test' });

    await adapter.generateSummary('meeting-123', true);

    expect(invoke).toHaveBeenCalledWith(
      'generate_summary',
      expect.objectContaining({
        force_regenerate: true,
      }),
    );
  });
});
```

---

## Acceptance Criteria

### Functional

- [ ] User can select tone (professional/casual/technical/friendly) in Settings
- [ ] User can select format (bullet_points/narrative/structured/concise) in Settings
- [ ] User can select verbosity (minimal/balanced/detailed/comprehensive) in Settings
- [ ] When generating summary, selected options affect the LLM system prompt
- [ ] Default behavior (no options) produces identical result to pre-feature baseline

### Technical

- [ ] `build_style_prompt()` logs unknown values at DEBUG level
- [ ] No breaking changes to existing clients (options field is optional)
- [ ] `SummarizationRequest.style_prompt` is `None` by default
- [ ] Providers prepend style to `SYSTEM_PROMPT` only when non-empty
- [ ] Unit tests cover all tone/format/verbosity combinations

### Quality Gates

- [ ] `pytest tests/quality/` passes (23 checks)
- [ ] `ruff check src/noteflow` clean
- [ ] `basedpyright` clean
- [ ] `npm run lint` passes (client)
- [ ] `cargo clippy` clean (Rust)

---

## Rollback Plan

If issues arise:

1. **Rust command**: Make `options` parameter ignored (don't pass to gRPC)
2. **Backend**: `build_style_prompt()` returns empty string for any input
3. **Providers**: Remove style prepending, use `SYSTEM_PROMPT` directly
4. **No proto changes needed**: Field is optional, empty string = no change

---

## Frontend/Backend Sync Protocol

### Architecture Decision: Per-Request Transmission

Preferences are stored **only on the frontend** (localStorage + Tauri state) and transmitted **per-request** via gRPC. The backend is stateless regarding user preferences.

```
┌─────────────────┐         ┌─────────────────┐         ┌─────────────────┐
│  ai_template    │────────▶│  Tauri Command  │────────▶│  gRPC Request   │
│  (preferences)  │  read   │  (summary.rs)   │  proto  │  (options field)│
└─────────────────┘         └─────────────────┘         └─────────────────┘
                                     │
                                     ▼
                            ┌─────────────────┐
                            │ build_style_    │
                            │ prompt()        │
                            └────────┬────────┘
                                     │
                                     ▼
                            ┌─────────────────┐
                            │ Provider:       │
                            │ style + SYSTEM_ │
                            │ PROMPT          │
                            └─────────────────┘
```

### Failure Handling

| Scenario | Behavior |
|----------|----------|
| Backend unreachable | Summary generation fails; preferences remain in storage |
| Invalid preference value | Backend logs at DEBUG, ignores value, uses default |
| Missing preferences | `options` field undefined; backend uses unmodified `SYSTEM_PROMPT` |
| Corrupted local storage | `getPreferences()` returns defaults; user re-configures |

---

## Dependencies

- **Sprint 0**: Proto schema with `SummarizationOptions` message (completed)

## Blocks

- None

## Post-Sprint

- [ ] Update CLAUDE.md with new `build_style_prompt()` utility
- [ ] Consider adding template presets ("Meeting Notes", "Executive Brief")
- [ ] Consider logging which style was used in summary metadata
