From c24b21f259a89ecef289a029580f1cf7b40d3cf6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 23 Mar 2026 20:24:51 -0400 Subject: [PATCH] docs: add Fable MCP implementation plan 17-task TDD plan covering Phase 1 (API key auth, search endpoint, conversation type wiring, Settings UI) and Phase 2 (fable-mcp Python package with all tool modules, tests, and Claude Code registration). Co-Authored-By: Claude Sonnet 4.6 --- docs/2026-03-23-fable-mcp-plan.md | 2059 +++++++++++++++++++++++++++++ 1 file changed, 2059 insertions(+) create mode 100644 docs/2026-03-23-fable-mcp-plan.md diff --git a/docs/2026-03-23-fable-mcp-plan.md b/docs/2026-03-23-fable-mcp-plan.md new file mode 100644 index 0000000..6a5b145 --- /dev/null +++ b/docs/2026-03-23-fable-mcp-plan.md @@ -0,0 +1,2059 @@ +# Fable MCP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add API key auth to Fabled Assistant and build a standalone Python MCP server that gives Claude full CRUD access to notes, tasks, projects, milestones, semantic search, and chat. + +**Architecture:** Phase 1 adds bearer token auth to the existing Quart app (new `api_keys` table, updated `_check_auth`, settings UI tab). Phase 2 is a standalone `fable-mcp/` Python package using the `mcp[cli]` SDK that calls the Fable HTTP API via `httpx`. The two phases are sequential — Phase 2 can be built/tested independently using a write-scoped API key once Phase 1 is done. + +**Tech Stack:** Python 3.12, Quart (Phase 1); `mcp[cli]`, `httpx`, `python-dotenv` (Phase 2); Vue 3 + TypeScript (Settings UI); pytest for both. + +**Testing convention:** Phase 1 tests run via `docker compose run --rm app pytest tests/ -v`. Phase 2 tests run locally with `cd fable-mcp && pytest -v`. All Phase 1 tests are unit tests of pure functions — mock DB calls with `unittest.mock.AsyncMock`; never connect to a real database in tests. + +**Important discovery:** `Conversation.conversation_type` column already exists in the model (`models/conversation.py:23`) and `list_conversations` already filters by it (`services/chat.py:49`). No migration needed for conversation types — only service/route wiring. + +--- + +## Phase 1: Fable API Key Feature + +### File Map + +| Action | File | Purpose | +|--------|------|---------| +| Create | `alembic/versions/0027_add_api_keys.py` | DB migration for `api_keys` table | +| Create | `src/fabledassistant/models/api_key.py` | `ApiKey` SQLAlchemy model | +| Modify | `src/fabledassistant/models/__init__.py` | Export `ApiKey` | +| Create | `src/fabledassistant/services/api_keys.py` | create/list/revoke/lookup service functions | +| Modify | `src/fabledassistant/auth.py` | Add bearer token check before session fallback | +| Create | `src/fabledassistant/routes/api_keys.py` | GET/POST/DELETE `/api/api-keys` blueprint | +| Modify | `src/fabledassistant/app.py` | Register `api_keys_bp` and `search_bp` | +| Modify | `src/fabledassistant/services/chat.py:17-30` | Add `conversation_type` param to `create_conversation` | +| Modify | `src/fabledassistant/services/chat.py:123-135` | Exclude `"mcp"` type from `cleanup_old_conversations` | +| Modify | `src/fabledassistant/routes/chat.py:73-79` | Pass `conversation_type` from POST body | +| Create | `src/fabledassistant/routes/search.py` | `GET /api/search` semantic search endpoint | +| Modify | `frontend/src/views/SettingsView.vue` | Add "API Keys" tab | +| Create | `tests/test_api_keys.py` | Unit tests for service + auth | +| Create | `tests/test_search_route.py` | Unit test for search endpoint | + +--- + +### Task 1: ApiKey model + migration + +**Files:** +- Create: `src/fabledassistant/models/api_key.py` +- Create: `alembic/versions/0027_add_api_keys.py` +- Modify: `src/fabledassistant/models/__init__.py` + +- [ ] **Step 1: Write the model** + +Create `src/fabledassistant/models/api_key.py`: + +```python +from datetime import datetime, timezone + +from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text +from sqlalchemy.orm import Mapped, mapped_column + +from fabledassistant.models import Base +from fabledassistant.models.base import CreatedAtMixin + + +class ApiKey(Base, CreatedAtMixin): + __tablename__ = "api_keys" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(Text, nullable=False) + key_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True) + key_prefix: Mapped[str] = mapped_column(Text, nullable=False) + scope: Mapped[str] = mapped_column(Text, nullable=False) # "read" or "write" + last_used_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + revoked_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + __table_args__ = ( + Index("ix_api_keys_user_id", "user_id"), + Index("ix_api_keys_key_hash", "key_hash"), + ) + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "key_prefix": self.key_prefix, + "scope": self.scope, + "last_used_at": self.last_used_at.isoformat() if self.last_used_at else None, + "created_at": self.created_at.isoformat(), + "revoked_at": self.revoked_at.isoformat() if self.revoked_at else None, + } +``` + +- [ ] **Step 2: Export from models __init__** + +In `src/fabledassistant/models/__init__.py`, add after the last import line: + +```python +from fabledassistant.models.api_key import ApiKey # noqa: E402, F401 +``` + +- [ ] **Step 3: Write the migration** + +Create `alembic/versions/0027_add_api_keys.py`: + +```python +"""add api_keys table + +Revision ID: 0027 +Revises: 0026 +Create Date: 2026-03-23 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0027" +down_revision = "0026" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "api_keys", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("key_hash", sa.Text(), nullable=False, unique=True), + sa.Column("key_prefix", sa.Text(), nullable=False), + sa.Column("scope", sa.Text(), nullable=False), + sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"]) + op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"]) + + +def downgrade() -> None: + op.drop_table("api_keys") +``` + +- [ ] **Step 4: Verify migration runs** + +```bash +docker compose run --rm app alembic upgrade head +``` + +Expected: `Running upgrade 0026 -> 0027, add api_keys table` + +- [ ] **Step 5: Commit** + +```bash +git add src/fabledassistant/models/api_key.py \ + src/fabledassistant/models/__init__.py \ + alembic/versions/0027_add_api_keys.py +git commit -m "feat: add ApiKey model and migration 0027" +``` + +--- + +### Task 2: ApiKey service + +**Files:** +- Create: `src/fabledassistant/services/api_keys.py` +- Create: `tests/test_api_keys.py` (service tests) + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_api_keys.py`: + +```python +"""Unit tests for api_keys service — pure logic, DB calls mocked.""" +import hashlib +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from fabledassistant.services.api_keys import ( + _hash_key, + generate_key, + create_api_key, + list_api_keys, + revoke_api_key, + lookup_key, +) + + +def test_generate_key_format(): + key = generate_key() + assert key.startswith("fmcp_") + assert len(key) > 12 # prefix + at least some random chars + + +def test_generate_key_uniqueness(): + keys = {generate_key() for _ in range(100)} + assert len(keys) == 100 # no duplicates + + +def test_hash_key_is_sha256(): + key = "fmcp_testkey" + h = _hash_key(key) + expected = hashlib.sha256(key.encode()).hexdigest() + assert h == expected + + +def test_generate_key_prefix(): + key = "fmcp_abcdefghijklmnop" + # prefix is first 12 chars of the full key + from fabledassistant.services.api_keys import _key_prefix + assert _key_prefix(key) == "fmcp_abcdefg" # first 12 chars + + +@pytest.mark.asyncio +async def test_create_api_key_returns_full_key(): + mock_key_obj = MagicMock() + mock_key_obj.id = 1 + mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"} + + with patch("fabledassistant.services.api_keys.async_session") as mock_session_ctx: + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + mock_session.add = MagicMock() + mock_session.commit = AsyncMock() + mock_session_ctx.return_value = mock_session + + full_key, key_dict = await create_api_key(user_id=1, name="test", scope="read") + + assert full_key.startswith("fmcp_") + assert isinstance(key_dict, dict) + + +@pytest.mark.asyncio +async def test_lookup_key_returns_none_for_unknown(): + with patch("fabledassistant.services.api_keys.async_session") as mock_session_ctx: + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + mock_result = MagicMock() + mock_result.scalars.return_value.first.return_value = None + mock_session.execute = AsyncMock(return_value=mock_result) + mock_session_ctx.return_value = mock_session + + result = await lookup_key("fmcp_doesnotexist") + + assert result is None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +docker compose run --rm app pytest tests/test_api_keys.py -v +``` + +Expected: `ImportError` or `ModuleNotFoundError` (service doesn't exist yet) + +- [ ] **Step 3: Write the service** + +Create `src/fabledassistant/services/api_keys.py`: + +```python +import hashlib +import secrets +from datetime import datetime, timezone + +from sqlalchemy import select + +from fabledassistant.models import async_session +from fabledassistant.models.api_key import ApiKey + + +def generate_key() -> str: + """Generate a new full API key. Never stored — caller must hash it.""" + return "fmcp_" + secrets.token_urlsafe(32) + + +def _hash_key(key: str) -> str: + return hashlib.sha256(key.encode()).hexdigest() + + +def _key_prefix(key: str) -> str: + """Return first 12 chars of the key for display (e.g. 'fmcp_abcdefg').""" + return key[:12] + + +async def create_api_key( + user_id: int, name: str, scope: str +) -> tuple[str, dict]: + """Create a new API key. Returns (full_key, key_dict). full_key is never stored.""" + if scope not in ("read", "write"): + raise ValueError("scope must be 'read' or 'write'") + full_key = generate_key() + key = ApiKey( + user_id=user_id, + name=name, + key_hash=_hash_key(full_key), + key_prefix=_key_prefix(full_key), + scope=scope, + ) + async with async_session() as session: + session.add(key) + await session.commit() + await session.refresh(key) + return full_key, key.to_dict() + + +async def list_api_keys(user_id: int) -> list[dict]: + """List all non-revoked API keys for the user.""" + async with async_session() as session: + result = await session.execute( + select(ApiKey) + .where(ApiKey.user_id == user_id, ApiKey.revoked_at.is_(None)) + .order_by(ApiKey.created_at.desc()) + ) + return [k.to_dict() for k in result.scalars().all()] + + +async def revoke_api_key(user_id: int, key_id: int) -> bool: + """Soft-delete a key by setting revoked_at. Returns True if found.""" + async with async_session() as session: + result = await session.execute( + select(ApiKey).where(ApiKey.id == key_id, ApiKey.user_id == user_id) + ) + key = result.scalars().first() + if key is None: + return False + key.revoked_at = datetime.now(timezone.utc) + await session.commit() + return True + + +async def lookup_key(raw_key: str) -> ApiKey | None: + """Look up a non-revoked ApiKey by raw token value. Updates last_used_at.""" + key_hash = _hash_key(raw_key) + async with async_session() as session: + result = await session.execute( + select(ApiKey).where( + ApiKey.key_hash == key_hash, + ApiKey.revoked_at.is_(None), + ) + ) + key = result.scalars().first() + if key is None: + return None + key.last_used_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(key) + return key +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +docker compose run --rm app pytest tests/test_api_keys.py -v +``` + +Expected: all tests pass + +- [ ] **Step 5: Commit** + +```bash +git add src/fabledassistant/services/api_keys.py tests/test_api_keys.py +git commit -m "feat: add ApiKey service with create/list/revoke/lookup" +``` + +--- + +### Task 3: Auth middleware — bearer token support + +**Files:** +- Modify: `src/fabledassistant/auth.py` +- Modify: `tests/test_api_keys.py` (add auth middleware tests) + +- [ ] **Step 1: Add auth middleware tests** + +Append to `tests/test_api_keys.py`: + +```python +def test_hash_key_deterministic(): + """Same input always produces same hash (needed for lookup).""" + key = "fmcp_some_test_key_value" + assert _hash_key(key) == _hash_key(key) + + +def test_scope_validation(): + with pytest.raises(ValueError, match="scope must be"): + import asyncio + asyncio.run(create_api_key(1, "bad", "superadmin")) + + +# --- Auth middleware tests --- + +@pytest.mark.asyncio +async def test_bearer_token_path_sets_g_user(monkeypatch): + """Valid bearer token authenticates and sets g.user and g.api_key.""" + from unittest.mock import AsyncMock, MagicMock + from fabledassistant.auth import _check_auth + + # Mock ApiKey object + fake_key = MagicMock() + fake_key.user_id = 7 + fake_key.scope = "write" + + # Mock User object + fake_user = MagicMock() + fake_user.role = "user" + + monkeypatch.setattr("fabledassistant.auth.lookup_key", AsyncMock(return_value=fake_key)) + monkeypatch.setattr("fabledassistant.auth.get_user_by_id", AsyncMock(return_value=fake_user)) + + called_with_user = {} + + async def view_func(): + from quart import g + called_with_user["user"] = g.user + called_with_user["api_key"] = g.api_key + from quart import jsonify + return jsonify({"ok": True}) + + from quart import Quart + app = Quart(__name__) + + @app.route("/test", methods=["GET"]) + @_check_auth + async def protected(): + return await view_func() + + async with app.test_request_context("/test", method="GET", + headers={"Authorization": "Bearer fmcp_valid"}): + # Just verify _check_auth calls lookup_key with the right token + import fabledassistant.auth as auth_module + auth_module.lookup_key.assert_called_with # callable + + +@pytest.mark.asyncio +async def test_read_only_key_blocked_on_post(): + """Read-only API key returns 403 on non-GET requests.""" + from unittest.mock import AsyncMock, MagicMock + from fabledassistant.auth import _check_auth + from quart import Quart + + fake_key = MagicMock() + fake_key.user_id = 7 + fake_key.scope = "read" + + fake_user = MagicMock() + fake_user.role = "user" + + app = Quart(__name__) + + with ( + app.test_request_context("/test", method="POST", + headers={"Authorization": "Bearer fmcp_readonly"}), + ): + from unittest.mock import patch + with patch("fabledassistant.auth.lookup_key", AsyncMock(return_value=fake_key)), \ + patch("fabledassistant.auth.get_user_by_id", AsyncMock(return_value=fake_user)): + + async def dummy(): + return "ok" + + wrapped = _check_auth(dummy) + from quart import current_app + async with app.app_context(): + resp = await wrapped() + # Should return 403 for read-only key on POST + assert resp[1] == 403 +``` + +- [ ] **Step 2: Run to verify pass** (these are pure logic tests) + +```bash +docker compose run --rm app pytest tests/test_api_keys.py -v +``` + +- [ ] **Step 3: Update auth.py** + +Replace `src/fabledassistant/auth.py` with: + +```python +import functools + +from quart import g, jsonify, request, session + +from fabledassistant.services.auth import get_user_by_id +from fabledassistant.services.api_keys import lookup_key + + +def _check_auth(f, required_role: str | None = None): + @functools.wraps(f) + async def decorated(*args, **kwargs): + # --- Bearer token path --- + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + raw_key = auth_header[len("Bearer "):] + api_key = await lookup_key(raw_key) + if api_key is None: + return jsonify({"error": "Invalid or revoked API key"}), 401 + user = await get_user_by_id(api_key.user_id) + if not user: + return jsonify({"error": "User not found"}), 401 + # Scope enforcement: read-only keys cannot mutate + if api_key.scope == "read" and request.method not in ("GET", "HEAD", "OPTIONS"): + return jsonify({"error": "Read-only key cannot perform write operations"}), 403 + # Role check (admin_required routes) + if required_role and user.role != required_role: + return jsonify({"error": "Admin access required"}), 403 + g.user = user + g.api_key = api_key + return await f(*args, **kwargs) + + # --- Session path (unchanged) --- + user_id = session.get("user_id") + if not user_id: + return jsonify({"error": "Authentication required"}), 401 + user = await get_user_by_id(user_id) + if not user: + session.clear() + return jsonify({"error": "Authentication required"}), 401 + if session.get("session_version") != user.session_version: + session.clear() + return jsonify({"error": "Session expired. Please log in again."}), 401 + if required_role and user.role != required_role: + return jsonify({"error": "Admin access required"}), 403 + g.user = user + return await f(*args, **kwargs) + return decorated + + +def login_required(f): + return _check_auth(f) + + +def admin_required(f): + return _check_auth(f, required_role="admin") + + +def get_current_user_id() -> int: + return g.user.id +``` + +- [ ] **Step 4: Run the full test suite to check for regressions** + +```bash +docker compose run --rm app pytest tests/ -v +``` + +Expected: all existing tests still pass + +- [ ] **Step 5: Commit** + +```bash +git add src/fabledassistant/auth.py tests/test_api_keys.py +git commit -m "feat: add bearer token auth to _check_auth, falls back to session" +``` + +--- + +### Task 4: API key routes + app registration + +**Files:** +- Create: `src/fabledassistant/routes/api_keys.py` +- Modify: `src/fabledassistant/app.py` + +- [ ] **Step 1: Write the routes** + +Create `src/fabledassistant/routes/api_keys.py`: + +```python +from quart import Blueprint, jsonify, request + +from fabledassistant.auth import login_required, get_current_user_id +from fabledassistant.services.api_keys import create_api_key, list_api_keys, revoke_api_key + +api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys") + + +@api_keys_bp.route("", methods=["GET"]) +@login_required +async def list_keys_route(): + uid = get_current_user_id() + keys = await list_api_keys(uid) + return jsonify({"api_keys": keys}) + + +@api_keys_bp.route("", methods=["POST"]) +@login_required +async def create_key_route(): + uid = get_current_user_id() + data = await request.get_json(force=True, silent=True) or {} + name = (data.get("name") or "").strip() + scope = (data.get("scope") or "").strip() + + if not name: + return jsonify({"error": "name is required"}), 400 + if scope not in ("read", "write"): + return jsonify({"error": "scope must be 'read' or 'write'"}), 400 + + full_key, key_dict = await create_api_key(uid, name=name, scope=scope) + # Return the full key ONCE — it is never retrievable again + return jsonify({"key": full_key, "api_key": key_dict}), 201 + + +@api_keys_bp.route("/", methods=["DELETE"]) +@login_required +async def revoke_key_route(key_id: int): + uid = get_current_user_id() + deleted = await revoke_api_key(uid, key_id) + if not deleted: + return jsonify({"error": "API key not found"}), 404 + return "", 204 +``` + +- [ ] **Step 2: Register in app.py** + +In `src/fabledassistant/app.py`, add the import alongside the other route imports: + +```python +from fabledassistant.routes.api_keys import api_keys_bp +``` + +And add the registration line after `app.register_blueprint(users_bp)`: + +```python +app.register_blueprint(api_keys_bp) +``` + +- [ ] **Step 3: Smoke test the endpoints** + +```bash +docker compose up -d +# Create a key +curl -s -X POST http://localhost:8080/api/api-keys \ + -H "Content-Type: application/json" \ + -H "Cookie: " \ + -d '{"name":"test","scope":"read"}' +# Expected: {"key": "fmcp_...", "api_key": {...}} + +# Test bearer auth +curl -s http://localhost:8080/api/auth/me \ + -H "Authorization: Bearer fmcp_" +# Expected: {"id": ..., "username": ...} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/fabledassistant/routes/api_keys.py src/fabledassistant/app.py +git commit -m "feat: add API key CRUD routes and register blueprint" +``` + +--- + +### Task 5: Conversation type wiring + +**Files:** +- Modify: `src/fabledassistant/services/chat.py` (lines 17-30 and 123-135) +- Modify: `src/fabledassistant/routes/chat.py` (lines 73-79) + +Note: `Conversation.conversation_type` already exists in the model. `list_conversations` already filters by `conv_type`. This task only wires up creation and retention exclusion. + +- [ ] **Step 1: Update `create_conversation` service** + +In `src/fabledassistant/services/chat.py`, change the function signature at line 17: + +```python +async def create_conversation( + user_id: int, title: str = "", model: str = "", conversation_type: str = "chat" +) -> Conversation: + async with async_session() as session: + conv = Conversation( + user_id=user_id, + title=title, + model=model, + conversation_type=conversation_type, + ) + session.add(conv) + await session.commit() + result = await session.execute( + select(Conversation) + .options(selectinload(Conversation.messages)) + .where(Conversation.id == conv.id) + ) + return result.scalars().first() +``` + +- [ ] **Step 2: Update `cleanup_old_conversations` to exclude "mcp"** + +In `src/fabledassistant/services/chat.py`, update the WHERE clause at line 130: + +```python +result = await session.execute( + sa_delete(Conversation) + .where( + Conversation.user_id == user_id, + Conversation.updated_at < cutoff, + Conversation.conversation_type != "mcp", # preserve MCP audit trail + ) + .returning(Conversation.id) +) +``` + +- [ ] **Step 3: Update the POST route to accept conversation_type** + +In `src/fabledassistant/routes/chat.py`, update `create_conversation_route` (around line 73): + +```python +@chat_bp.route("/conversations", methods=["POST"]) +@login_required +async def create_conversation_route(): + uid = get_current_user_id() + data = await request.get_json(force=True, silent=True) or {} + title = data.get("title", "") + model = data.get("model", Config.OLLAMA_MODEL) + conversation_type = data.get("conversation_type", "chat") + # Only allow known types to prevent accidental misuse + if conversation_type not in ("chat", "mcp"): + conversation_type = "chat" + conv = await create_conversation(uid, title=title, model=model, conversation_type=conversation_type) + return jsonify(conv.to_dict()), 201 +``` + +- [ ] **Step 4: Run tests** + +```bash +docker compose run --rm app pytest tests/ -v +``` + +Expected: all tests pass + +- [ ] **Step 5: Commit** + +```bash +git add src/fabledassistant/services/chat.py src/fabledassistant/routes/chat.py +git commit -m "feat: wire conversation_type through create_conversation, exclude mcp from retention sweep" +``` + +--- + +### Task 6: Semantic search endpoint + +**Files:** +- Create: `src/fabledassistant/routes/search.py` +- Modify: `src/fabledassistant/app.py` +- Create: `tests/test_search_route.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_search_route.py`: + +```python +"""Unit tests for the search route parameter mapping.""" +import pytest +from unittest.mock import patch, AsyncMock + + +def test_content_type_mapping(): + """Verify content_type string maps to correct is_task value.""" + from fabledassistant.routes.search import _content_type_to_is_task + assert _content_type_to_is_task("note") is False + assert _content_type_to_is_task("task") is True + assert _content_type_to_is_task("all") is None + assert _content_type_to_is_task("unknown") is None # default to all +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +docker compose run --rm app pytest tests/test_search_route.py -v +``` + +Expected: `ImportError` (module doesn't exist yet) + +- [ ] **Step 3: Write the route** + +Create `src/fabledassistant/routes/search.py`: + +```python +from quart import Blueprint, jsonify, request + +from fabledassistant.auth import login_required, get_current_user_id +from fabledassistant.services.embeddings import semantic_search_notes + +search_bp = Blueprint("search", __name__, url_prefix="/api/search") + + +def _content_type_to_is_task(content_type: str) -> bool | None: + """Map content_type query param to semantic_search_notes is_task arg.""" + if content_type == "note": + return False + if content_type == "task": + return True + return None # "all" or unknown → no filter + + +@search_bp.route("", methods=["GET"]) +@login_required +async def search_route(): + uid = get_current_user_id() + q = (request.args.get("q") or "").strip() + if not q: + return jsonify({"error": "q is required"}), 400 + + content_type = request.args.get("content_type", "all") + limit = min(request.args.get("limit", 10, type=int), 50) + is_task = _content_type_to_is_task(content_type) + + results = await semantic_search_notes( + uid, q, limit=limit, is_task=is_task, threshold=0.3 + ) + return jsonify({ + "results": [ + { + "id": note.id, + "title": note.title, + "body": note.body or "", + "is_task": note.is_task, + "tags": note.tags or [], + "similarity": score, + } + for score, note in results # semantic_search_notes returns list[tuple[float, Note]] + ], + "total": len(results), + }) +``` + +Note: check `services/embeddings.py` to confirm the return type of `semantic_search_notes` — it should return a list of `(Note, float)` tuples. If the signature differs, adjust the destructuring above accordingly. + +- [ ] **Step 4: Register in app.py** + +Add to imports in `src/fabledassistant/app.py`: +```python +from fabledassistant.routes.search import search_bp +``` + +Add registration: +```python +app.register_blueprint(search_bp) +``` + +- [ ] **Step 5: Run tests** + +```bash +docker compose run --rm app pytest tests/test_search_route.py tests/ -v +``` + +Expected: all tests pass + +- [ ] **Step 6: Commit** + +```bash +git add src/fabledassistant/routes/search.py \ + src/fabledassistant/app.py \ + tests/test_search_route.py +git commit -m "feat: add GET /api/search semantic search endpoint" +``` + +--- + +### Task 7: Settings UI — API Keys tab + +**Files:** +- Modify: `frontend/src/views/SettingsView.vue` + +- [ ] **Step 1: Add "apikeys" to VALID_TABS** + +In `SettingsView.vue`, find the `VALID_TABS` array (search for `VALID_TABS`) and add `"apikeys"` to it. + +- [ ] **Step 2: Add sidebar tab button** + +In the settings sidebar tab list, add an "API Keys" tab button alongside the existing ones, following the same pattern as existing tabs (look for the ` + + + +
+

Copy this key now — it will not be shown again.

+
+ {{ newKeyValue }} + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + +
NameScopePrefixLast Used
{{ key.name }}{{ key.scope }}{{ key.key_prefix }}…{{ key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' }} + + + Sure? + + + +
+
+

No API keys yet.

+ +``` + +- [ ] **Step 4: Add reactive state and methods to the script section** + +In the `