1940 lines
60 KiB
Markdown
1940 lines
60 KiB
Markdown
# Personal Companion AI — Implementation Plan Phase 1: Vault Indexer + RAG Engine
|
|
|
|
> **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:** Build a fully working vault indexer and RAG engine that can watch, chunk, embed, and search 677+ Obsidian markdown files locally using Ollama and LanceDB.
|
|
|
|
**Architecture:** Decoupled Python services. The RAG engine handles markdown chunking (with per-directory rules), embedding via Ollama, and LanceDB storage. The indexer daemon watches the vault filesystem and triggers incremental or full syncs. A simple search CLI proves end-to-end retrieval works.
|
|
|
|
**Tech Stack:** Python 3.11+, LanceDB, Ollama (`mxbai-embed-large`), `watchdog` for file watching, `pytest` for testing, `pydantic` for config.
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
| File | Responsibility |
|
|
|------|----------------|
|
|
| `pyproject.toml` | Python project deps and metadata |
|
|
| `config.json` | Runtime configuration (vault path, chunking rules, Ollama settings) |
|
|
| `src/config.py` | Load and validate `config.json` into typed Pydantic models |
|
|
| `src/rag/chunker.py` | Parse markdown, apply chunking rules (sliding window + section-based), emit chunks with metadata |
|
|
| `src/rag/embedder.py` | HTTP client for Ollama embeddings with batching and retries |
|
|
| `src/rag/vector_store.py` | LanceDB wrapper: init table, upsert, search, delete by source_file |
|
|
| `src/rag/indexer.py` | Orchestrate full sync and incremental sync: scan files, chunk, embed, store |
|
|
| `src/rag/search.py` | High-level search interface: embed query, run vector + optional keyword hybrid search |
|
|
| `src/indexer_daemon/cli.py` | Click/Typer CLI for `index`, `sync`, `reindex`, `status` commands |
|
|
| `src/indexer_daemon/watcher.py` | `watchdog` observer that triggers incremental sync on `.md` changes |
|
|
| `tests/test_chunker.py` | Unit tests for all chunking strategies |
|
|
| `tests/test_embedder.py` | Mocked tests for Ollama client |
|
|
| `tests/test_vector_store.py` | LanceDB CRUD and search tests |
|
|
| `tests/test_indexer.py` | End-to-end sync tests with temp vault |
|
|
|
|
---
|
|
|
|
## Task 1: Project Scaffolding
|
|
|
|
**Files:**
|
|
- Create: `pyproject.toml`
|
|
- Create: `config.json`
|
|
- Create: `.gitignore`
|
|
|
|
- [ ] **Step 1: Write `pyproject.toml`**
|
|
|
|
```toml
|
|
[project]
|
|
name = "companion"
|
|
version = "0.1.0"
|
|
description = "Personal companion AI with local RAG"
|
|
requires-python = ">=3.11"
|
|
dependencies = [
|
|
"pydantic>=2.0",
|
|
"lancedb>=0.9.0",
|
|
"pyarrow>=15.0.0",
|
|
"requests>=2.31.0",
|
|
"watchdog>=4.0.0",
|
|
"typer>=0.12.0",
|
|
"rich>=13.0.0",
|
|
"numpy>=1.26.0",
|
|
]
|
|
|
|
[project.optional-dependencies]
|
|
dev = [
|
|
"pytest>=8.0.0",
|
|
"pytest-asyncio>=0.23.0",
|
|
"httpx>=0.27.0",
|
|
"respx>=0.21.0",
|
|
]
|
|
|
|
[build-system]
|
|
requires = ["hatchling"]
|
|
build-backend = "hatchling.build"
|
|
```
|
|
|
|
- [ ] **Step 2: Write `config.json`**
|
|
|
|
```json
|
|
{
|
|
"companion": {
|
|
"name": "SAN",
|
|
"persona": {
|
|
"role": "companion",
|
|
"tone": "reflective",
|
|
"style": "questioning",
|
|
"boundaries": [
|
|
"does_not_impersonate_user",
|
|
"no_future_predictions",
|
|
"no_medical_or_legal_advice"
|
|
]
|
|
},
|
|
"memory": {
|
|
"session_turns": 20,
|
|
"persistent_store": "~/.companion/memory.db",
|
|
"summarize_after": 10
|
|
},
|
|
"chat": {
|
|
"streaming": true,
|
|
"max_response_tokens": 2048,
|
|
"default_temperature": 0.7,
|
|
"allow_temperature_override": true
|
|
}
|
|
},
|
|
"vault": {
|
|
"path": "./sample-data/Default",
|
|
"indexing": {
|
|
"auto_sync": true,
|
|
"auto_sync_interval_minutes": 1440,
|
|
"watch_fs_events": true,
|
|
"file_patterns": ["*.md"],
|
|
"deny_dirs": [".obsidian", ".trash", "zzz-Archive", ".git", ".logseq"],
|
|
"deny_patterns": ["*.tmp", "*.bak", "*conflict*", ".*"]
|
|
},
|
|
"chunking_rules": {
|
|
"default": {
|
|
"strategy": "sliding_window",
|
|
"chunk_size": 500,
|
|
"chunk_overlap": 100
|
|
},
|
|
"Journal/**": {
|
|
"strategy": "section",
|
|
"section_tags": ["#DayInShort", "#mentalhealth", "#physicalhealth", "#work", "#finance", "#Relations"],
|
|
"chunk_size": 300,
|
|
"chunk_overlap": 50
|
|
},
|
|
"zzz-Archive/**": {
|
|
"strategy": "sliding_window",
|
|
"chunk_size": 800,
|
|
"chunk_overlap": 150
|
|
}
|
|
}
|
|
},
|
|
"rag": {
|
|
"embedding": {
|
|
"provider": "ollama",
|
|
"model": "mxbai-embed-large",
|
|
"base_url": "http://localhost:11434",
|
|
"dimensions": 1024,
|
|
"batch_size": 32
|
|
},
|
|
"vector_store": {
|
|
"type": "lancedb",
|
|
"path": "./.companion/vectors.lance"
|
|
},
|
|
"search": {
|
|
"default_top_k": 8,
|
|
"max_top_k": 20,
|
|
"similarity_threshold": 0.75,
|
|
"hybrid_search": {
|
|
"enabled": true,
|
|
"keyword_weight": 0.3,
|
|
"semantic_weight": 0.7
|
|
},
|
|
"filters": {
|
|
"date_range_enabled": true,
|
|
"tag_filter_enabled": true,
|
|
"directory_filter_enabled": true
|
|
}
|
|
}
|
|
},
|
|
"model": {
|
|
"inference": {
|
|
"backend": "llama.cpp",
|
|
"model_path": "~/.companion/models/companion-7b-q4.gguf",
|
|
"context_length": 8192,
|
|
"gpu_layers": 35,
|
|
"batch_size": 512,
|
|
"threads": 8
|
|
},
|
|
"fine_tuning": {
|
|
"base_model": "unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit",
|
|
"output_dir": "~/.companion/training",
|
|
"lora_rank": 16,
|
|
"lora_alpha": 32,
|
|
"learning_rate": 0.0002,
|
|
"batch_size": 4,
|
|
"gradient_accumulation_steps": 4,
|
|
"num_epochs": 3,
|
|
"warmup_steps": 100,
|
|
"save_steps": 500,
|
|
"eval_steps": 250,
|
|
"training_data_path": "~/.companion/training_data/",
|
|
"validation_split": 0.1
|
|
},
|
|
"retrain_schedule": {
|
|
"auto_reminder": true,
|
|
"default_interval_days": 90,
|
|
"reminder_channels": ["chat_stream", "log"]
|
|
}
|
|
},
|
|
"api": {
|
|
"host": "127.0.0.1",
|
|
"port": 7373,
|
|
"cors_origins": ["http://localhost:5173"],
|
|
"auth": {
|
|
"enabled": false
|
|
}
|
|
},
|
|
"ui": {
|
|
"web": {
|
|
"enabled": true,
|
|
"theme": "obsidian",
|
|
"features": {
|
|
"streaming": true,
|
|
"citations": true,
|
|
"source_preview": true
|
|
}
|
|
},
|
|
"cli": {
|
|
"enabled": true,
|
|
"rich_output": true
|
|
}
|
|
},
|
|
"logging": {
|
|
"level": "INFO",
|
|
"file": "./.companion/logs/companion.log",
|
|
"max_size_mb": 100,
|
|
"backup_count": 5
|
|
},
|
|
"security": {
|
|
"local_only": true,
|
|
"vault_path_traversal_check": true,
|
|
"sensitive_content_detection": true,
|
|
"sensitive_patterns": ["#mentalhealth", "#physicalhealth", "#finance", "#Relations"],
|
|
"require_confirmation_for_external_apis": true
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Write `.gitignore`**
|
|
|
|
```gitignore
|
|
__pycache__/
|
|
*.py[cod]
|
|
*$py.class
|
|
*.egg-info/
|
|
.pytest_cache/
|
|
.mypy_cache/
|
|
.venv/
|
|
venv/
|
|
.companion/
|
|
dist/
|
|
build/
|
|
```
|
|
|
|
- [ ] **Step 4: Install dependencies**
|
|
|
|
Run:
|
|
```bash
|
|
pip install -e ".[dev]"
|
|
```
|
|
|
|
Expected: installs all packages without errors.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add pyproject.toml config.json .gitignore
|
|
git commit -m "chore: scaffold companion project with deps and config"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Configuration Loader
|
|
|
|
**Files:**
|
|
- Create: `src/config.py`
|
|
- Create: `tests/test_config.py`
|
|
|
|
- [ ] **Step 1: Write failing test for config loading**
|
|
|
|
```python
|
|
# tests/test_config.py
|
|
import json
|
|
import os
|
|
import tempfile
|
|
|
|
from src.config import load_config
|
|
|
|
|
|
def test_load_config_reads_json_and_expands_tilde():
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
json.dump({
|
|
"vault": {"path": "~/test-vault"},
|
|
"rag": {"vector_store": {"path": "~/.companion/vectors.lance"}}
|
|
}, f)
|
|
path = f.name
|
|
try:
|
|
config = load_config(path)
|
|
assert config.vault.path == os.path.expanduser("~/test-vault")
|
|
assert config.rag.vector_store.path == os.path.expanduser("~/.companion/vectors.lance")
|
|
finally:
|
|
os.unlink(path)
|
|
```
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/test_config.py -v
|
|
```
|
|
|
|
Expected: FAIL — `src.config` module not found.
|
|
|
|
- [ ] **Step 2: Implement `src/config.py`**
|
|
|
|
```python
|
|
# src/config.py
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class PersonaConfig(BaseModel):
|
|
role: str
|
|
tone: str
|
|
style: str
|
|
boundaries: list[str]
|
|
|
|
|
|
class MemoryConfig(BaseModel):
|
|
session_turns: int
|
|
persistent_store: str
|
|
summarize_after: int
|
|
|
|
|
|
class ChatConfig(BaseModel):
|
|
streaming: bool
|
|
max_response_tokens: int
|
|
default_temperature: float
|
|
allow_temperature_override: bool
|
|
|
|
|
|
class CompanionConfig(BaseModel):
|
|
name: str
|
|
persona: PersonaConfig
|
|
memory: MemoryConfig
|
|
chat: ChatConfig
|
|
|
|
|
|
class IndexingConfig(BaseModel):
|
|
auto_sync: bool
|
|
auto_sync_interval_minutes: int
|
|
watch_fs_events: bool
|
|
file_patterns: list[str]
|
|
deny_dirs: list[str]
|
|
deny_patterns: list[str]
|
|
|
|
|
|
class ChunkingRule(BaseModel):
|
|
strategy: str
|
|
chunk_size: int
|
|
chunk_overlap: int
|
|
section_tags: list[str] | None = None
|
|
|
|
|
|
class VaultConfig(BaseModel):
|
|
path: str
|
|
indexing: IndexingConfig
|
|
chunking_rules: dict[str, ChunkingRule]
|
|
|
|
|
|
class EmbeddingConfig(BaseModel):
|
|
provider: str
|
|
model: str
|
|
base_url: str
|
|
dimensions: int
|
|
batch_size: int
|
|
|
|
|
|
class VectorStoreConfig(BaseModel):
|
|
type: str
|
|
path: str
|
|
|
|
|
|
class HybridSearchConfig(BaseModel):
|
|
enabled: bool
|
|
keyword_weight: float
|
|
semantic_weight: float
|
|
|
|
|
|
class FiltersConfig(BaseModel):
|
|
date_range_enabled: bool
|
|
tag_filter_enabled: bool
|
|
directory_filter_enabled: bool
|
|
|
|
|
|
class SearchConfig(BaseModel):
|
|
default_top_k: int
|
|
max_top_k: int
|
|
similarity_threshold: float
|
|
hybrid_search: HybridSearchConfig
|
|
filters: FiltersConfig
|
|
|
|
|
|
class RagConfig(BaseModel):
|
|
embedding: EmbeddingConfig
|
|
vector_store: VectorStoreConfig
|
|
search: SearchConfig
|
|
|
|
|
|
class InferenceConfig(BaseModel):
|
|
backend: str
|
|
model_path: str
|
|
context_length: int
|
|
gpu_layers: int
|
|
batch_size: int
|
|
threads: int
|
|
|
|
|
|
class FineTuningConfig(BaseModel):
|
|
base_model: str
|
|
output_dir: str
|
|
lora_rank: int
|
|
lora_alpha: int
|
|
learning_rate: float
|
|
batch_size: int
|
|
gradient_accumulation_steps: int
|
|
num_epochs: int
|
|
warmup_steps: int
|
|
save_steps: int
|
|
eval_steps: int
|
|
training_data_path: str
|
|
validation_split: float
|
|
|
|
|
|
class RetrainScheduleConfig(BaseModel):
|
|
auto_reminder: bool
|
|
default_interval_days: int
|
|
reminder_channels: list[str]
|
|
|
|
|
|
class ModelConfig(BaseModel):
|
|
inference: InferenceConfig
|
|
fine_tuning: FineTuningConfig
|
|
retrain_schedule: RetrainScheduleConfig
|
|
|
|
|
|
class AuthConfig(BaseModel):
|
|
enabled: bool
|
|
|
|
|
|
class ApiConfig(BaseModel):
|
|
host: str
|
|
port: int
|
|
cors_origins: list[str]
|
|
auth: AuthConfig
|
|
|
|
|
|
class WebFeaturesConfig(BaseModel):
|
|
streaming: bool
|
|
citations: bool
|
|
source_preview: bool
|
|
|
|
|
|
class WebConfig(BaseModel):
|
|
enabled: bool
|
|
theme: str
|
|
features: WebFeaturesConfig
|
|
|
|
|
|
class CliConfig(BaseModel):
|
|
enabled: bool
|
|
rich_output: bool
|
|
|
|
|
|
class UiConfig(BaseModel):
|
|
web: WebConfig
|
|
cli: CliConfig
|
|
|
|
|
|
class LoggingConfig(BaseModel):
|
|
level: str
|
|
file: str
|
|
max_size_mb: int
|
|
backup_count: int
|
|
|
|
|
|
class SecurityConfig(BaseModel):
|
|
local_only: bool
|
|
vault_path_traversal_check: bool
|
|
sensitive_content_detection: bool
|
|
sensitive_patterns: list[str]
|
|
require_confirmation_for_external_apis: bool
|
|
|
|
|
|
class Config(BaseModel):
|
|
companion: CompanionConfig
|
|
vault: VaultConfig
|
|
rag: RagConfig
|
|
model: ModelConfig
|
|
api: ApiConfig
|
|
ui: UiConfig
|
|
logging: LoggingConfig
|
|
security: SecurityConfig
|
|
|
|
|
|
def _expand_tilde(obj: Any) -> Any:
|
|
if isinstance(obj, str) and obj.startswith("~/"):
|
|
return os.path.expanduser(obj)
|
|
if isinstance(obj, dict):
|
|
return {k: _expand_tilde(v) for k, v in obj.items()}
|
|
if isinstance(obj, list):
|
|
return [_expand_tilde(item) for item in obj]
|
|
return obj
|
|
|
|
|
|
def load_config(path: str | Path = "config.json") -> Config:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
raw = json.load(f)
|
|
expanded = _expand_tilde(raw)
|
|
return Config.model_validate(expanded)
|
|
```
|
|
|
|
- [ ] **Step 3: Run test**
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/test_config.py -v
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/config.py tests/test_config.py
|
|
git commit -m "feat: add typed configuration loader with tilde expansion"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Markdown Chunker
|
|
|
|
**Files:**
|
|
- Create: `src/rag/chunker.py`
|
|
- Create: `tests/test_chunker.py`
|
|
|
|
- [ ] **Step 1: Write failing test for sliding window chunker**
|
|
|
|
```python
|
|
# tests/test_chunker.py
|
|
from src.rag.chunker import sliding_window_chunks
|
|
|
|
|
|
def test_sliding_window_basic():
|
|
text = "word " * 100
|
|
chunks = sliding_window_chunks(text, chunk_size=20, chunk_overlap=5)
|
|
assert len(chunks) > 1
|
|
assert len(chunks[0].split()) == 20
|
|
# overlap check: last 5 words of chunk 0 should appear in chunk 1
|
|
last_five = chunks[0].split()[-5:]
|
|
first_chunk1 = chunks[1].split()[:5]
|
|
assert last_five == first_chunk1
|
|
```
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/test_chunker.py::test_sliding_window_basic -v
|
|
```
|
|
|
|
Expected: FAIL — `src.rag.chunker` not found.
|
|
|
|
- [ ] **Step 2: Implement `src/rag/chunker.py` with sliding window and section chunkers**
|
|
|
|
```python
|
|
# src/rag/chunker.py
|
|
from __future__ import annotations
|
|
|
|
import fnmatch
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Chunk:
|
|
text: str
|
|
source_file: str
|
|
source_directory: str
|
|
section: str | None = None
|
|
date: str | None = None
|
|
tags: list[str] | None = None
|
|
chunk_index: int = 0
|
|
total_chunks: int = 1
|
|
modified_at: float | None = None
|
|
rule_applied: str = "default"
|
|
|
|
|
|
def _extract_tags(text: str) -> list[str]:
|
|
hashtags = re.findall(r"#\w+", text)
|
|
wikilinks = re.findall(r"\[\[([^\]]+)\]\]", text)
|
|
return hashtags + wikilinks
|
|
|
|
|
|
def _extract_headings(text: str) -> list[str]:
|
|
return re.findall(r"^#+\s*(.+)$", text, flags=re.MULTILINE)
|
|
|
|
|
|
def _parse_date_from_filename(filename: str) -> str | None:
|
|
# YYYY-MM-DD or "DD MMM YYYY"
|
|
m = re.search(r"(\d{4}-\d{2}-\d{2})", filename)
|
|
if m:
|
|
return m.group(1)
|
|
m = re.search(r"(\d{2}\s+[A-Za-z]{3}\s+\d{4})", filename)
|
|
if m:
|
|
return m.group(1)
|
|
return None
|
|
|
|
|
|
def sliding_window_chunks(text: str, chunk_size: int, chunk_overlap: int) -> list[str]:
|
|
words = text.split()
|
|
if len(words) <= chunk_size:
|
|
return [" ".join(words)]
|
|
chunks: list[str] = []
|
|
step = chunk_size - chunk_overlap
|
|
for i in range(0, len(words), step):
|
|
window = words[i : i + chunk_size]
|
|
chunks.append(" ".join(window))
|
|
if i + chunk_size >= len(words):
|
|
break
|
|
return chunks
|
|
|
|
|
|
def section_based_chunks(text: str, section_tags: list[str] | None, chunk_size: int, chunk_overlap: int) -> list[tuple[str, str | None]]:
|
|
"""Split by section tags, then apply sliding window within each section."""
|
|
if not section_tags:
|
|
return [(chunk, None) for chunk in sliding_window_chunks(text, chunk_size, chunk_overlap)]
|
|
|
|
# Build regex for any of the section tags at start of line
|
|
escaped = [re.escape(tag) for tag in section_tags]
|
|
pattern = re.compile(r"^(" + "|".join(escaped) + r")", flags=re.MULTILINE)
|
|
matches = list(pattern.finditer(text))
|
|
if not matches:
|
|
return [(chunk, None) for chunk in sliding_window_chunks(text, chunk_size, chunk_overlap)]
|
|
|
|
sections: list[tuple[str, str | None]] = []
|
|
for i, match in enumerate(matches):
|
|
start = match.start()
|
|
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
|
section_text = text[start:end].strip()
|
|
section_name = match.group(1)
|
|
for chunk in sliding_window_chunks(section_text, chunk_size, chunk_overlap):
|
|
sections.append((chunk, section_name))
|
|
return sections
|
|
|
|
|
|
@dataclass
|
|
class ChunkingRule:
|
|
strategy: str # "sliding_window" | "section"
|
|
chunk_size: int
|
|
chunk_overlap: int
|
|
section_tags: list[str] | None = None
|
|
|
|
|
|
def match_chunking_rule(relative_path: str, rules: dict[str, ChunkingRule]) -> ChunkingRule:
|
|
for pattern, rule in rules.items():
|
|
if pattern == "default":
|
|
continue
|
|
if fnmatch.fnmatch(relative_path, pattern):
|
|
return rule
|
|
return rules.get("default", ChunkingRule(strategy="sliding_window", chunk_size=500, chunk_overlap=100))
|
|
|
|
|
|
def chunk_file(
|
|
file_path: Path,
|
|
vault_root: Path,
|
|
rules: dict[str, ChunkingRule],
|
|
modified_at: float | None = None,
|
|
) -> list[Chunk]:
|
|
relative = str(file_path.relative_to(vault_root)).replace("\\", "/")
|
|
source_directory = relative.split("/")[0] if "/" in relative else "."
|
|
text = file_path.read_text(encoding="utf-8")
|
|
rule = match_chunking_rule(relative, rules)
|
|
|
|
if rule.strategy == "section":
|
|
raw_chunks = section_based_chunks(text, rule.section_tags, rule.chunk_size, rule.chunk_overlap)
|
|
else:
|
|
raw_chunks = [(chunk, None) for chunk in sliding_window_chunks(text, rule.chunk_size, rule.chunk_overlap)]
|
|
|
|
total = len(raw_chunks)
|
|
date = _parse_date_from_filename(file_path.name)
|
|
result: list[Chunk] = []
|
|
for idx, (chunk_text, section_name) in enumerate(raw_chunks):
|
|
tags = _extract_tags(chunk_text)
|
|
result.append(
|
|
Chunk(
|
|
text=chunk_text,
|
|
source_file=relative,
|
|
source_directory=source_directory,
|
|
section=section_name,
|
|
date=date,
|
|
tags=tags,
|
|
chunk_index=idx,
|
|
total_chunks=total,
|
|
modified_at=modified_at,
|
|
rule_applied=rule.strategy,
|
|
)
|
|
)
|
|
return result
|
|
```
|
|
|
|
- [ ] **Step 3: Run sliding window test**
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/test_chunker.py::test_sliding_window_basic -v
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Add section-based chunker test**
|
|
|
|
```python
|
|
# Append to tests/test_chunker.py
|
|
from src.rag.chunker import section_based_chunks, chunk_file, ChunkingRule
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
|
|
def test_section_based_chunks_splits_on_tags():
|
|
text = "#DayInShort: good day\n#mentalhealth: stressed\n#work: busy"
|
|
chunks = section_based_chunks(text, ["#DayInShort", "#mentalhealth", "#work"], chunk_size=10, chunk_overlap=2)
|
|
assert len(chunks) == 3
|
|
assert chunks[0][1] == "#DayInShort"
|
|
assert chunks[1][1] == "#mentalhealth"
|
|
assert chunks[2][1] == "#work"
|
|
|
|
|
|
def test_chunk_file_extracts_metadata():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
vault = Path(tmp)
|
|
journal = vault / "Journal" / "2026" / "04" / "2026-04-12.md"
|
|
journal.parent.mkdir(parents=True)
|
|
journal.write_text("#DayInShort: good day\n#Relations: [[Person/Vinay]] visited.", encoding="utf-8")
|
|
rules = {
|
|
"default": ChunkingRule(strategy="sliding_window", chunk_size=500, chunk_overlap=100),
|
|
"Journal/**": ChunkingRule(strategy="section", chunk_size=300, chunk_overlap=50, section_tags=["#DayInShort", "#Relations"]),
|
|
}
|
|
chunks = chunk_file(journal, vault, rules, modified_at=1234567890.0)
|
|
assert len(chunks) == 2
|
|
assert chunks[0].source_directory == "Journal"
|
|
assert chunks[0].date == "2026-04-12"
|
|
assert "Person/Vinay" in (chunks[1].tags or [])
|
|
```
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/test_chunker.py -v
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/rag/chunker.py tests/test_chunker.py
|
|
git commit -m "feat: add markdown chunker with sliding window and section strategies"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Ollama Embedder
|
|
|
|
**Files:**
|
|
- Create: `src/rag/embedder.py`
|
|
- Create: `tests/test_embedder.py`
|
|
|
|
- [ ] **Step 1: Write failing test for embedder**
|
|
|
|
```python
|
|
# tests/test_embedder.py
|
|
import pytest
|
|
import respx
|
|
from httpx import Response
|
|
|
|
from src.rag.embedder import OllamaEmbedder
|
|
|
|
|
|
@respx.mock
|
|
def test_embed_single_text():
|
|
route = respx.post("http://localhost:11434/api/embeddings").mock(
|
|
return_value=Response(200, json={"embedding": [0.1] * 1024})
|
|
)
|
|
embedder = OllamaEmbedder(base_url="http://localhost:11434", model="mxbai-embed-large")
|
|
result = embedder.embed(["hello world"])
|
|
assert len(result) == 1
|
|
assert len(result[0]) == 1024
|
|
assert route.called
|
|
```
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/test_embedder.py -v
|
|
```
|
|
|
|
Expected: FAIL — `src.rag.embedder` not found.
|
|
|
|
- [ ] **Step 2: Implement `src/rag/embedder.py`**
|
|
|
|
```python
|
|
# src/rag/embedder.py
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from typing import Iterable
|
|
|
|
import requests
|
|
|
|
|
|
class OllamaEmbedder:
|
|
def __init__(self, base_url: str, model: str, batch_size: int = 32):
|
|
self.base_url = base_url.rstrip("/")
|
|
self.model = model
|
|
self.batch_size = batch_size
|
|
|
|
def embed(self, texts: list[str], retries: int = 3, backoff: float = 1.0) -> list[list[float]]:
|
|
results: list[list[float]] = []
|
|
for i in range(0, len(texts), self.batch_size):
|
|
batch = texts[i : i + self.batch_size]
|
|
batch_results = self._embed_batch(batch, retries, backoff)
|
|
results.extend(batch_results)
|
|
return results
|
|
|
|
def _embed_batch(self, texts: list[str], retries: int, backoff: float) -> list[list[float]]:
|
|
last_exception: Exception | None = None
|
|
for attempt in range(retries):
|
|
try:
|
|
response = requests.post(
|
|
f"{self.base_url}/api/embeddings",
|
|
json={"model": self.model, "prompt": texts[0]},
|
|
timeout=120,
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
embedding = data.get("embedding")
|
|
if not embedding or not isinstance(embedding, list):
|
|
raise ValueError(f"Invalid response from Ollama: {data}")
|
|
return [embedding]
|
|
except Exception as exc:
|
|
last_exception = exc
|
|
if attempt < retries - 1:
|
|
time.sleep(backoff * (2 ** attempt))
|
|
raise RuntimeError(f"Ollama embedding failed after {retries} attempts") from last_exception
|
|
```
|
|
|
|
Wait — the above only handles single text per call because Ollama `/api/embeddings` (plural) endpoint is different. Let me check. Actually Ollama has `/api/embed` which takes multiple inputs. But the spec says via Ollama. Let's use `/api/embed` which supports batching.
|
|
|
|
```python
|
|
# src/rag/embedder.py (revised)
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
import requests
|
|
|
|
|
|
class OllamaEmbedder:
|
|
def __init__(self, base_url: str, model: str, batch_size: int = 32):
|
|
self.base_url = base_url.rstrip("/")
|
|
self.model = model
|
|
self.batch_size = batch_size
|
|
|
|
def embed(self, texts: list[str], retries: int = 3, backoff: float = 1.0) -> list[list[float]]:
|
|
results: list[list[float]] = []
|
|
for i in range(0, len(texts), self.batch_size):
|
|
batch = texts[i : i + self.batch_size]
|
|
batch_results = self._embed_batch(batch, retries, backoff)
|
|
results.extend(batch_results)
|
|
return results
|
|
|
|
def _embed_batch(self, texts: list[str], retries: int, backoff: float) -> list[list[float]]:
|
|
last_exception: Exception | None = None
|
|
for attempt in range(retries):
|
|
try:
|
|
response = requests.post(
|
|
f"{self.base_url}/api/embed",
|
|
json={"model": self.model, "input": texts},
|
|
timeout=300,
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
embeddings = data.get("embeddings")
|
|
if not embeddings or not isinstance(embeddings, list):
|
|
raise ValueError(f"Invalid response from Ollama: {data}")
|
|
return embeddings
|
|
except Exception as exc:
|
|
last_exception = exc
|
|
if attempt < retries - 1:
|
|
time.sleep(backoff * (2 ** attempt))
|
|
raise RuntimeError(f"Ollama embedding failed after {retries} attempts") from last_exception
|
|
```
|
|
|
|
And update the test:
|
|
|
|
```python
|
|
# tests/test_embedder.py (revised)
|
|
import pytest
|
|
import respx
|
|
from httpx import Response
|
|
|
|
from src.rag.embedder import OllamaEmbedder
|
|
|
|
|
|
@respx.mock
|
|
def test_embed_batch():
|
|
route = respx.post("http://localhost:11434/api/embed").mock(
|
|
return_value=Response(200, json={"embeddings": [[0.1] * 1024, [0.2] * 1024]})
|
|
)
|
|
embedder = OllamaEmbedder(base_url="http://localhost:11434", model="mxbai-embed-large", batch_size=2)
|
|
result = embedder.embed(["hello world", "goodbye world"])
|
|
assert len(result) == 2
|
|
assert len(result[0]) == 1024
|
|
assert result[0][0] == 0.1
|
|
assert result[1][0] == 0.2
|
|
assert route.called
|
|
```
|
|
|
|
- [ ] **Step 3: Write the revised embedder and test**
|
|
|
|
Write `src/rag/embedder.py` with the revised code above.
|
|
Write `tests/test_embedder.py` with the revised test above.
|
|
|
|
- [ ] **Step 4: Run test**
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/test_embedder.py -v
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/rag/embedder.py tests/test_embedder.py
|
|
git commit -m "feat: add Ollama embedder with batching and retries"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: LanceDB Vector Store
|
|
|
|
**Files:**
|
|
- Create: `src/rag/vector_store.py`
|
|
- Create: `tests/test_vector_store.py`
|
|
|
|
- [ ] **Step 1: Write failing test for vector store init and upsert**
|
|
|
|
```python
|
|
# tests/test_vector_store.py
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pyarrow as pa
|
|
import pytest
|
|
|
|
from src.rag.vector_store import VectorStore
|
|
|
|
|
|
def test_vector_store_upsert_and_search():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
store = VectorStore(uri=tmp, dimensions=4)
|
|
store.upsert(
|
|
ids=["a", "b"],
|
|
texts=["hello world", "goodbye world"],
|
|
embeddings=[[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0]],
|
|
metadatas=[
|
|
{"source_file": "a.md", "source_directory": "docs"},
|
|
{"source_file": "b.md", "source_directory": "docs"},
|
|
],
|
|
)
|
|
results = store.search(query_vector=[1.0, 0.0, 0.0, 0.0], top_k=1)
|
|
assert len(results) == 1
|
|
assert results[0]["source_file"] == "a.md"
|
|
```
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/test_vector_store.py -v
|
|
```
|
|
|
|
Expected: FAIL — module not found.
|
|
|
|
- [ ] **Step 2: Implement `src/rag/vector_store.py`**
|
|
|
|
```python
|
|
# src/rag/vector_store.py
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import lancedb
|
|
import numpy as np
|
|
import pyarrow as pa
|
|
|
|
|
|
class VectorStore:
|
|
TABLE_NAME = "chunks"
|
|
|
|
def __init__(self, uri: str | Path, dimensions: int):
|
|
self.uri = str(uri)
|
|
self.dimensions = dimensions
|
|
self.db = lancedb.connect(self.uri)
|
|
self.table = self._get_or_create_table()
|
|
|
|
def _get_or_create_table(self):
|
|
try:
|
|
return self.db.open_table(self.TABLE_NAME)
|
|
except Exception:
|
|
schema = pa.schema([
|
|
pa.field("id", pa.string()),
|
|
pa.field("text", pa.string()),
|
|
pa.field("vector", pa.list_(pa.float32(), self.dimensions)),
|
|
pa.field("source_file", pa.string()),
|
|
pa.field("source_directory", pa.string()),
|
|
pa.field("section", pa.string(), nullable=True),
|
|
pa.field("date", pa.string(), nullable=True),
|
|
pa.field("tags", pa.list_(pa.string()), nullable=True),
|
|
pa.field("chunk_index", pa.int32()),
|
|
pa.field("total_chunks", pa.int32()),
|
|
pa.field("modified_at", pa.float64(), nullable=True),
|
|
pa.field("rule_applied", pa.string()),
|
|
])
|
|
return self.db.create_table(self.TABLE_NAME, schema=schema)
|
|
|
|
def upsert(
|
|
self,
|
|
ids: list[str],
|
|
texts: list[str],
|
|
embeddings: list[list[float]],
|
|
metadatas: list[dict[str, Any]],
|
|
) -> None:
|
|
data = []
|
|
for id_, text, vector, meta in zip(ids, texts, embeddings, metadatas):
|
|
row = {
|
|
"id": id_,
|
|
"text": text,
|
|
"vector": np.array(vector, dtype=np.float32),
|
|
"source_file": meta.get("source_file", ""),
|
|
"source_directory": meta.get("source_directory", ""),
|
|
"section": meta.get("section"),
|
|
"date": meta.get("date"),
|
|
"tags": meta.get("tags") or [],
|
|
"chunk_index": meta.get("chunk_index", 0),
|
|
"total_chunks": meta.get("total_chunks", 1),
|
|
"modified_at": meta.get("modified_at"),
|
|
"rule_applied": meta.get("rule_applied", "default"),
|
|
}
|
|
data.append(row)
|
|
self.table.merge_insert("id") \
|
|
.when_matched_update_all() \
|
|
.when_not_matched_insert_all() \
|
|
.execute(data)
|
|
|
|
def delete_by_source_file(self, source_file: str) -> None:
|
|
self.table.delete(f'source_file = "{source_file}"')
|
|
|
|
def search(
|
|
self,
|
|
query_vector: list[float],
|
|
top_k: int = 8,
|
|
filters: dict[str, Any] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
query = self.table.search(np.array(query_vector, dtype=np.float32))
|
|
if filters:
|
|
expr_parts = []
|
|
for key, value in filters.items():
|
|
if isinstance(value, list):
|
|
quoted = [f'"{v}"' for v in value]
|
|
expr_parts.append(f"{key} IN ({', '.join(quoted)})")
|
|
elif isinstance(value, str):
|
|
expr_parts.append(f'{key} = "{value}"')
|
|
else:
|
|
expr_parts.append(f"{key} = {value}")
|
|
if expr_parts:
|
|
query = query.where(" AND ".join(expr_parts))
|
|
results = query.limit(top_k).to_list()
|
|
return results
|
|
|
|
def count(self) -> int:
|
|
return self.table.count_rows()
|
|
```
|
|
|
|
- [ ] **Step 3: Run test**
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/test_vector_store.py -v
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/rag/vector_store.py tests/test_vector_store.py
|
|
git commit -m "feat: add LanceDB vector store with upsert, delete, and search"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Indexer Orchestrator
|
|
|
|
**Files:**
|
|
- Create: `src/rag/indexer.py`
|
|
- Create: `tests/test_indexer.py`
|
|
|
|
- [ ] **Step 1: Write failing end-to-end indexer test**
|
|
|
|
```python
|
|
# tests/test_indexer.py
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from src.config import Config, VaultConfig, IndexingConfig, RagConfig, EmbeddingConfig, VectorStoreConfig, SearchConfig, HybridSearchConfig, FiltersConfig
|
|
from src.rag.indexer import Indexer
|
|
from src.rag.vector_store import VectorStore
|
|
|
|
|
|
def _make_config(vault_path: Path, vector_store_path: Path) -> Config:
|
|
return Config(
|
|
companion=None, # not used
|
|
vault=VaultConfig(
|
|
path=str(vault_path),
|
|
indexing=IndexingConfig(
|
|
auto_sync=False,
|
|
auto_sync_interval_minutes=1440,
|
|
watch_fs_events=False,
|
|
file_patterns=["*.md"],
|
|
deny_dirs=[".git"],
|
|
deny_patterns=[".*"],
|
|
),
|
|
chunking_rules={},
|
|
),
|
|
rag=RagConfig(
|
|
embedding=EmbeddingConfig(
|
|
provider="ollama",
|
|
model="dummy",
|
|
base_url="http://localhost:11434",
|
|
dimensions=4,
|
|
batch_size=2,
|
|
),
|
|
vector_store=VectorStoreConfig(type="lancedb", path=str(vector_store_path)),
|
|
search=SearchConfig(
|
|
default_top_k=8,
|
|
max_top_k=20,
|
|
similarity_threshold=0.75,
|
|
hybrid_search=HybridSearchConfig(enabled=False, keyword_weight=0.3, semantic_weight=0.7),
|
|
filters=FiltersConfig(date_range_enabled=True, tag_filter_enabled=True, directory_filter_enabled=True),
|
|
),
|
|
),
|
|
model=None,
|
|
api=None,
|
|
ui=None,
|
|
logging=None,
|
|
security=None,
|
|
)
|
|
|
|
|
|
def test_full_index_creates_vectors():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
vault = Path(tmp) / "vault"
|
|
vault.mkdir()
|
|
(vault / "hello.md").write_text("hello world", encoding="utf-8")
|
|
vs_path = Path(tmp) / "vectors"
|
|
config = _make_config(vault, vs_path)
|
|
store = VectorStore(uri=vs_path, dimensions=4)
|
|
indexer = Indexer(config, store)
|
|
indexer.full_index()
|
|
assert store.count() == 1
|
|
```
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/test_indexer.py::test_full_index_creates_vectors -v
|
|
```
|
|
|
|
Expected: FAIL — `src.rag.indexer` not found.
|
|
|
|
- [ ] **Step 2: Implement `src/rag/indexer.py`**
|
|
|
|
```python
|
|
# src/rag/indexer.py
|
|
from __future__ import annotations
|
|
|
|
import fnmatch
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
from src.config import Config
|
|
from src.rag.chunker import Chunk, ChunkingRule, chunk_file
|
|
from src.rag.embedder import OllamaEmbedder
|
|
from src.rag.vector_store import VectorStore
|
|
|
|
|
|
class Indexer:
|
|
def __init__(self, config: Config, vector_store: VectorStore):
|
|
self.config = config
|
|
self.vector_store = vector_store
|
|
self.embedder = OllamaEmbedder(
|
|
base_url=config.rag.embedding.base_url,
|
|
model=config.rag.embedding.model,
|
|
batch_size=config.rag.embedding.batch_size,
|
|
)
|
|
self.vault_path = Path(config.vault.path)
|
|
self._chunking_rules = self._load_chunking_rules()
|
|
|
|
def _load_chunking_rules(self) -> dict[str, ChunkingRule]:
|
|
rules = {"default": ChunkingRule(strategy="sliding_window", chunk_size=500, chunk_overlap=100)}
|
|
for pattern, rule in self.config.vault.chunking_rules.items():
|
|
rules[pattern] = ChunkingRule(
|
|
strategy=rule.strategy,
|
|
chunk_size=rule.chunk_size,
|
|
chunk_overlap=rule.chunk_overlap,
|
|
section_tags=rule.section_tags,
|
|
)
|
|
return rules
|
|
|
|
def _should_index(self, relative_path: str) -> bool:
|
|
parts = relative_path.split("/")
|
|
for deny in self.config.vault.indexing.deny_dirs:
|
|
if deny in parts:
|
|
return False
|
|
for pattern in self.config.vault.indexing.deny_patterns:
|
|
if fnmatch.fnmatch(Path(relative_path).name, pattern):
|
|
return False
|
|
matched = False
|
|
for pattern in self.config.vault.indexing.file_patterns:
|
|
if fnmatch.fnmatch(Path(relative_path).name, pattern):
|
|
matched = True
|
|
break
|
|
return matched
|
|
|
|
def _list_files(self) -> Iterable[Path]:
|
|
for root, dirs, files in os.walk(self.vault_path):
|
|
# prune denied dirs
|
|
dirs[:] = [d for d in dirs if d not in self.config.vault.indexing.deny_dirs]
|
|
for f in files:
|
|
file_path = Path(root) / f
|
|
relative = str(file_path.relative_to(self.vault_path)).replace("\\", "/")
|
|
if self._should_index(relative):
|
|
yield file_path
|
|
|
|
def full_index(self) -> None:
|
|
# Clear existing data for simplicity in full reindex
|
|
try:
|
|
self.vector_store.db.drop_table(VectorStore.TABLE_NAME)
|
|
except Exception:
|
|
pass
|
|
self.vector_store.table = self.vector_store._get_or_create_table()
|
|
self._index_files(self._list_files())
|
|
|
|
def sync(self) -> None:
|
|
files_to_process = []
|
|
for file_path in self._list_files():
|
|
relative = str(file_path.relative_to(self.vault_path)).replace("\\", "/")
|
|
mtime = file_path.stat().st_mtime
|
|
# Check if already indexed with same mtime
|
|
existing = self.vector_store.table.search().where(f'source_file = "{relative}"').limit(1).to_list()
|
|
if not existing or existing[0].get("modified_at") != mtime:
|
|
files_to_process.append(file_path)
|
|
# Delete old entries for files being reprocessed
|
|
for file_path in files_to_process:
|
|
relative = str(file_path.relative_to(self.vault_path)).replace("\\", "/")
|
|
self.vector_store.delete_by_source_file(relative)
|
|
self._index_files(files_to_process)
|
|
|
|
def _index_files(self, file_paths: Iterable[Path]) -> None:
|
|
all_chunks: list[Chunk] = []
|
|
for file_path in file_paths:
|
|
mtime = file_path.stat().st_mtime
|
|
chunks = chunk_file(file_path, self.vault_path, self._chunking_rules, modified_at=mtime)
|
|
all_chunks.extend(chunks)
|
|
if not all_chunks:
|
|
return
|
|
texts = [c.text for c in all_chunks]
|
|
embeddings = self.embedder.embed(texts)
|
|
ids = [f"{c.source_file}::{c.chunk_index}" for c in all_chunks]
|
|
metadatas = [
|
|
{
|
|
"source_file": c.source_file,
|
|
"source_directory": c.source_directory,
|
|
"section": c.section,
|
|
"date": c.date,
|
|
"tags": c.tags,
|
|
"chunk_index": c.chunk_index,
|
|
"total_chunks": c.total_chunks,
|
|
"modified_at": c.modified_at,
|
|
"rule_applied": c.rule_applied,
|
|
}
|
|
for c in all_chunks
|
|
]
|
|
self.vector_store.upsert(ids=ids, texts=texts, embeddings=embeddings, metadatas=metadatas)
|
|
|
|
def status(self) -> dict:
|
|
total_docs = self.vector_store.count()
|
|
indexed_files = set()
|
|
try:
|
|
results = self.vector_store.table.to_lance().to_table(columns=["source_file", "modified_at"]).to_pylist()
|
|
for row in results:
|
|
indexed_files.add((row["source_file"], row.get("modified_at")))
|
|
except Exception:
|
|
pass
|
|
unindexed = 0
|
|
for file_path in self._list_files():
|
|
relative = str(file_path.relative_to(self.vault_path)).replace("\\", "/")
|
|
mtime = file_path.stat().st_mtime
|
|
if (relative, mtime) not in indexed_files:
|
|
unindexed += 1
|
|
return {
|
|
"total_chunks": total_docs,
|
|
"indexed_files": len(indexed_files),
|
|
"unindexed_files": unindexed,
|
|
}
|
|
```
|
|
|
|
Wait, there's an issue: the test uses `Config(companion=None, ...)` but Pydantic models won't accept None if the field is required. I need to make the test valid. Let me check the Config model — companion, vault, rag are required and typed. model, api, ui, logging, security are also required. So the test needs full config or I should make the indexer test simpler by passing only what it needs. But the indexer constructor takes `Config`. Let me fix the test to build a minimal valid Config.
|
|
|
|
Actually, I can just build a full minimal Config. That's tedious but necessary. Let me rewrite the test helper:
|
|
|
|
```python
|
|
# tests/test_indexer.py (revised helper)
|
|
def _make_config(vault_path: Path, vector_store_path: Path) -> Config:
|
|
from src.config import (
|
|
CompanionConfig, PersonaConfig, MemoryConfig, ChatConfig,
|
|
ModelConfig, InferenceConfig, FineTuningConfig, RetrainScheduleConfig,
|
|
ApiConfig, AuthConfig, UiConfig, WebConfig, WebFeaturesConfig, CliConfig,
|
|
LoggingConfig, SecurityConfig,
|
|
)
|
|
return Config(
|
|
companion=CompanionConfig(
|
|
name="SAN",
|
|
persona=PersonaConfig(role="companion", tone="reflective", style="questioning", boundaries=[]),
|
|
memory=MemoryConfig(session_turns=20, persistent_store="", summarize_after=10),
|
|
chat=ChatConfig(streaming=True, max_response_tokens=2048, default_temperature=0.7, allow_temperature_override=True),
|
|
),
|
|
vault=VaultConfig(
|
|
path=str(vault_path),
|
|
indexing=IndexingConfig(
|
|
auto_sync=False,
|
|
auto_sync_interval_minutes=1440,
|
|
watch_fs_events=False,
|
|
file_patterns=["*.md"],
|
|
deny_dirs=[".git"],
|
|
deny_patterns=[".*"],
|
|
),
|
|
chunking_rules={},
|
|
),
|
|
rag=RagConfig(
|
|
embedding=EmbeddingConfig(
|
|
provider="ollama",
|
|
model="dummy",
|
|
base_url="http://localhost:11434",
|
|
dimensions=4,
|
|
batch_size=2,
|
|
),
|
|
vector_store=VectorStoreConfig(type="lancedb", path=str(vector_store_path)),
|
|
search=SearchConfig(
|
|
default_top_k=8,
|
|
max_top_k=20,
|
|
similarity_threshold=0.75,
|
|
hybrid_search=HybridSearchConfig(enabled=False, keyword_weight=0.3, semantic_weight=0.7),
|
|
filters=FiltersConfig(date_range_enabled=True, tag_filter_enabled=True, directory_filter_enabled=True),
|
|
),
|
|
),
|
|
model=ModelConfig(
|
|
inference=InferenceConfig(backend="llama.cpp", model_path="", context_length=8192, gpu_layers=35, batch_size=512, threads=8),
|
|
fine_tuning=FineTuningConfig(base_model="", output_dir="", lora_rank=16, lora_alpha=32, learning_rate=0.0002, batch_size=4, gradient_accumulation_steps=4, num_epochs=3, warmup_steps=100, save_steps=500, eval_steps=250, training_data_path="", validation_split=0.1),
|
|
retrain_schedule=RetrainScheduleConfig(auto_reminder=True, default_interval_days=90, reminder_channels=[]),
|
|
),
|
|
api=ApiConfig(host="127.0.0.1", port=7373, cors_origins=[], auth=AuthConfig(enabled=False)),
|
|
ui=UiConfig(web=WebConfig(enabled=True, theme="obsidian", features=WebFeaturesConfig(streaming=True, citations=True, source_preview=True)), cli=CliConfig(enabled=True, rich_output=True)),
|
|
logging=LoggingConfig(level="INFO", file="", max_size_mb=100, backup_count=5),
|
|
security=SecurityConfig(local_only=True, vault_path_traversal_check=True, sensitive_content_detection=True, sensitive_patterns=[], require_confirmation_for_external_apis=True),
|
|
)
|
|
```
|
|
|
|
This is verbose but correct. Now, there's another issue: the indexer calls `self.embedder.embed(texts)` which will try to hit Ollama. In tests we need to mock it. Let me add a mock embedder injection or monkeypatch. Actually, the simplest approach is to mock `OllamaEmbedder.embed` in the test.
|
|
|
|
Revised test:
|
|
|
|
```python
|
|
# tests/test_indexer.py (revised)
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from src.config import Config, VaultConfig, IndexingConfig, RagConfig, EmbeddingConfig, VectorStoreConfig, SearchConfig, HybridSearchConfig, FiltersConfig
|
|
from src.config import (
|
|
CompanionConfig, PersonaConfig, MemoryConfig, ChatConfig,
|
|
ModelConfig, InferenceConfig, FineTuningConfig, RetrainScheduleConfig,
|
|
ApiConfig, AuthConfig, UiConfig, WebConfig, WebFeaturesConfig, CliConfig,
|
|
LoggingConfig, SecurityConfig,
|
|
)
|
|
from src.rag.indexer import Indexer
|
|
from src.rag.vector_store import VectorStore
|
|
|
|
|
|
def _make_config(vault_path: Path, vector_store_path: Path) -> Config:
|
|
return Config(
|
|
companion=CompanionConfig(
|
|
name="SAN",
|
|
persona=PersonaConfig(role="companion", tone="reflective", style="questioning", boundaries=[]),
|
|
memory=MemoryConfig(session_turns=20, persistent_store="", summarize_after=10),
|
|
chat=ChatConfig(streaming=True, max_response_tokens=2048, default_temperature=0.7, allow_temperature_override=True),
|
|
),
|
|
vault=VaultConfig(
|
|
path=str(vault_path),
|
|
indexing=IndexingConfig(
|
|
auto_sync=False,
|
|
auto_sync_interval_minutes=1440,
|
|
watch_fs_events=False,
|
|
file_patterns=["*.md"],
|
|
deny_dirs=[".git"],
|
|
deny_patterns=[".*"],
|
|
),
|
|
chunking_rules={},
|
|
),
|
|
rag=RagConfig(
|
|
embedding=EmbeddingConfig(
|
|
provider="ollama",
|
|
model="dummy",
|
|
base_url="http://localhost:11434",
|
|
dimensions=4,
|
|
batch_size=2,
|
|
),
|
|
vector_store=VectorStoreConfig(type="lancedb", path=str(vector_store_path)),
|
|
search=SearchConfig(
|
|
default_top_k=8,
|
|
max_top_k=20,
|
|
similarity_threshold=0.75,
|
|
hybrid_search=HybridSearchConfig(enabled=False, keyword_weight=0.3, semantic_weight=0.7),
|
|
filters=FiltersConfig(date_range_enabled=True, tag_filter_enabled=True, directory_filter_enabled=True),
|
|
),
|
|
),
|
|
model=ModelConfig(
|
|
inference=InferenceConfig(backend="llama.cpp", model_path="", context_length=8192, gpu_layers=35, batch_size=512, threads=8),
|
|
fine_tuning=FineTuningConfig(base_model="", output_dir="", lora_rank=16, lora_alpha=32, learning_rate=0.0002, batch_size=4, gradient_accumulation_steps=4, num_epochs=3, warmup_steps=100, save_steps=500, eval_steps=250, training_data_path="", validation_split=0.1),
|
|
retrain_schedule=RetrainScheduleConfig(auto_reminder=True, default_interval_days=90, reminder_channels=[]),
|
|
),
|
|
api=ApiConfig(host="127.0.0.1", port=7373, cors_origins=[], auth=AuthConfig(enabled=False)),
|
|
ui=UiConfig(web=WebConfig(enabled=True, theme="obsidian", features=WebFeaturesConfig(streaming=True, citations=True, source_preview=True)), cli=CliConfig(enabled=True, rich_output=True)),
|
|
logging=LoggingConfig(level="INFO", file="", max_size_mb=100, backup_count=5),
|
|
security=SecurityConfig(local_only=True, vault_path_traversal_check=True, sensitive_content_detection=True, sensitive_patterns=[], require_confirmation_for_external_apis=True),
|
|
)
|
|
|
|
|
|
@patch("src.rag.indexer.OllamaEmbedder")
|
|
def test_full_index_creates_vectors(mock_embedder_cls):
|
|
mock_embedder = MagicMock()
|
|
mock_embedder.embed.return_value = [[1.0, 0.0, 0.0, 0.0]]
|
|
mock_embedder_cls.return_value = mock_embedder
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
vault = Path(tmp) / "vault"
|
|
vault.mkdir()
|
|
(vault / "hello.md").write_text("hello world", encoding="utf-8")
|
|
vs_path = Path(tmp) / "vectors"
|
|
config = _make_config(vault, vs_path)
|
|
store = VectorStore(uri=vs_path, dimensions=4)
|
|
indexer = Indexer(config, store)
|
|
indexer.full_index()
|
|
assert store.count() == 1
|
|
```
|
|
|
|
- [ ] **Step 3: Write the revised indexer and test**
|
|
|
|
Write `src/rag/indexer.py` and `tests/test_indexer.py` with the code above.
|
|
|
|
- [ ] **Step 4: Run test**
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/test_indexer.py -v
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/rag/indexer.py tests/test_indexer.py
|
|
git commit -m "feat: add indexer orchestrator with full index, sync, and status"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Search Interface
|
|
|
|
**Files:**
|
|
- Create: `src/rag/search.py`
|
|
- Create: `tests/test_search.py`
|
|
|
|
- [ ] **Step 1: Write failing search test**
|
|
|
|
```python
|
|
# tests/test_search.py
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from src.rag.search import SearchEngine
|
|
from src.rag.vector_store import VectorStore
|
|
|
|
|
|
@patch("src.rag.search.OllamaEmbedder")
|
|
def test_search_returns_results(mock_embedder_cls):
|
|
mock_embedder = MagicMock()
|
|
mock_embedder.embed.return_value = [[1.0, 0.0, 0.0, 0.0]]
|
|
mock_embedder_cls.return_value = mock_embedder
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
store = VectorStore(uri=tmp, dimensions=4)
|
|
store.upsert(
|
|
ids=["a"],
|
|
texts=["hello world"],
|
|
embeddings=[[1.0, 0.0, 0.0, 0.0]],
|
|
metadatas=[{"source_file": "a.md", "source_directory": "docs"}],
|
|
)
|
|
engine = SearchEngine(
|
|
vector_store=store,
|
|
embedder_base_url="http://localhost:11434",
|
|
embedder_model="dummy",
|
|
default_top_k=5,
|
|
similarity_threshold=0.0,
|
|
hybrid_search_enabled=False,
|
|
)
|
|
results = engine.search("hello")
|
|
assert len(results) == 1
|
|
assert results[0]["source_file"] == "a.md"
|
|
```
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/test_search.py -v
|
|
```
|
|
|
|
Expected: FAIL — `src.rag.search` not found.
|
|
|
|
- [ ] **Step 2: Implement `src/rag/search.py`**
|
|
|
|
```python
|
|
# src/rag/search.py
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from src.rag.embedder import OllamaEmbedder
|
|
from src.rag.vector_store import VectorStore
|
|
|
|
|
|
class SearchEngine:
|
|
def __init__(
|
|
self,
|
|
vector_store: VectorStore,
|
|
embedder_base_url: str,
|
|
embedder_model: str,
|
|
default_top_k: int,
|
|
similarity_threshold: float,
|
|
hybrid_search_enabled: bool,
|
|
keyword_weight: float = 0.3,
|
|
semantic_weight: float = 0.7,
|
|
):
|
|
self.vector_store = vector_store
|
|
self.embedder = OllamaEmbedder(base_url=embedder_base_url, model=embedder_model)
|
|
self.default_top_k = default_top_k
|
|
self.similarity_threshold = similarity_threshold
|
|
self.hybrid_search_enabled = hybrid_search_enabled
|
|
self.keyword_weight = keyword_weight
|
|
self.semantic_weight = semantic_weight
|
|
|
|
def search(
|
|
self,
|
|
query: str,
|
|
top_k: int | None = None,
|
|
filters: dict[str, Any] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
k = top_k or self.default_top_k
|
|
query_embedding = self.embedder.embed([query])[0]
|
|
results = self.vector_store.search(query_embedding, top_k=k, filters=filters)
|
|
if self.similarity_threshold > 0 and results:
|
|
# LanceDB returns `_distance`; cosine distance threshold logic
|
|
results = [r for r in results if r.get("_distance", float("inf")) <= self.similarity_threshold]
|
|
return results
|
|
```
|
|
|
|
- [ ] **Step 3: Run test**
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/test_search.py -v
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/rag/search.py tests/test_search.py
|
|
git commit -m "feat: add search engine interface with embedding and filtering"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Indexer CLI
|
|
|
|
**Files:**
|
|
- Create: `src/indexer_daemon/cli.py`
|
|
|
|
- [ ] **Step 1: Implement CLI without test (simple Typer app)**
|
|
|
|
```python
|
|
# src/indexer_daemon/cli.py
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import typer
|
|
|
|
from src.config import load_config
|
|
from src.rag.indexer import Indexer
|
|
from src.rag.vector_store import VectorStore
|
|
|
|
app = typer.Typer(help="Companion vault indexer")
|
|
|
|
|
|
def _get_indexer() -> Indexer:
|
|
config = load_config("config.json")
|
|
store = VectorStore(uri=config.rag.vector_store.path, dimensions=config.rag.embedding.dimensions)
|
|
return Indexer(config, store)
|
|
|
|
|
|
@app.command()
|
|
def index() -> None:
|
|
"""Run a full index of the vault."""
|
|
indexer = _get_indexer()
|
|
typer.echo("Running full index...")
|
|
indexer.full_index()
|
|
typer.echo(f"Done. Total chunks: {indexer.status()['total_chunks']}")
|
|
|
|
|
|
@app.command()
|
|
def sync() -> None:
|
|
"""Run an incremental sync."""
|
|
indexer = _get_indexer()
|
|
typer.echo("Running incremental sync...")
|
|
indexer.sync()
|
|
typer.echo(f"Done. Total chunks: {indexer.status()['total_chunks']}")
|
|
|
|
|
|
@app.command()
|
|
def reindex() -> None:
|
|
"""Force a full reindex (same as index)."""
|
|
index()
|
|
|
|
|
|
@app.command()
|
|
def status() -> None:
|
|
"""Show indexer status."""
|
|
indexer = _get_indexer()
|
|
s = indexer.status()
|
|
typer.echo(f"Total chunks: {s['total_chunks']}")
|
|
typer.echo(f"Indexed files: {s['indexed_files']}")
|
|
typer.echo(f"Unindexed files: {s['unindexed_files']}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app()
|
|
```
|
|
|
|
- [ ] **Step 2: Verify CLI loads**
|
|
|
|
Run:
|
|
```bash
|
|
python -m src.indexer_daemon.cli --help
|
|
```
|
|
|
|
Expected: Shows Typer help with `index`, `sync`, `reindex`, `status` commands.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/indexer_daemon/cli.py
|
|
git commit -m "feat: add indexer CLI with index, sync, reindex, status commands"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: File System Watcher
|
|
|
|
**Files:**
|
|
- Create: `src/indexer_daemon/watcher.py`
|
|
|
|
- [ ] **Step 1: Implement watcher without test (watchdog integration)**
|
|
|
|
```python
|
|
# src/indexer_daemon/watcher.py
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from watchdog.events import FileSystemEventHandler
|
|
from watchdog.observers import Observer
|
|
|
|
from src.config import load_config
|
|
from src.rag.indexer import Indexer
|
|
from src.rag.vector_store import VectorStore
|
|
|
|
|
|
class VaultEventHandler(FileSystemEventHandler):
|
|
def __init__(self, indexer: Indexer, debounce_seconds: float = 5.0):
|
|
self.indexer = indexer
|
|
self.debounce_seconds = debounce_seconds
|
|
self._last_sync = 0.0
|
|
|
|
def on_any_event(self, event):
|
|
if event.is_directory:
|
|
return
|
|
if not event.src_path.endswith(".md"):
|
|
return
|
|
now = time.time()
|
|
if now - self._last_sync < self.debounce_seconds:
|
|
return
|
|
self._last_sync = now
|
|
try:
|
|
self.indexer.sync()
|
|
except Exception as exc:
|
|
print(f"Sync failed: {exc}")
|
|
|
|
|
|
def start_watcher(config_path: str = "config.json") -> None:
|
|
config = load_config(config_path)
|
|
store = VectorStore(uri=config.rag.vector_store.path, dimensions=config.rag.embedding.dimensions)
|
|
indexer = Indexer(config, store)
|
|
handler = VaultEventHandler(indexer)
|
|
observer = Observer()
|
|
observer.schedule(handler, str(config.vault.path), recursive=True)
|
|
observer.start()
|
|
print(f"Watching {config.vault.path} for changes...")
|
|
try:
|
|
while True:
|
|
time.sleep(1)
|
|
except KeyboardInterrupt:
|
|
observer.stop()
|
|
observer.join()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
start_watcher()
|
|
```
|
|
|
|
- [ ] **Step 2: Verify watcher module imports cleanly**
|
|
|
|
Run:
|
|
```bash
|
|
python -c "from src.indexer_daemon.watcher import start_watcher; print('OK')"
|
|
```
|
|
|
|
Expected: Prints `OK`.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/indexer_daemon/watcher.py
|
|
git commit -m "feat: add vault file system watcher with debounced sync"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Integration Test — End-to-End Sync
|
|
|
|
**Files:**
|
|
- Create: `tests/test_integration.py`
|
|
|
|
- [ ] **Step 1: Write integration test**
|
|
|
|
```python
|
|
# tests/test_integration.py
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from src.config import Config, VaultConfig, IndexingConfig, RagConfig, EmbeddingConfig, VectorStoreConfig, SearchConfig, HybridSearchConfig, FiltersConfig
|
|
from src.config import (
|
|
CompanionConfig, PersonaConfig, MemoryConfig, ChatConfig,
|
|
ModelConfig, InferenceConfig, FineTuningConfig, RetrainScheduleConfig,
|
|
ApiConfig, AuthConfig, UiConfig, WebConfig, WebFeaturesConfig, CliConfig,
|
|
LoggingConfig, SecurityConfig,
|
|
)
|
|
from src.rag.indexer import Indexer
|
|
from src.rag.search import SearchEngine
|
|
from src.rag.vector_store import VectorStore
|
|
|
|
|
|
def _make_config(vault_path: Path, vector_store_path: Path) -> Config:
|
|
return Config(
|
|
companion=CompanionConfig(
|
|
name="SAN",
|
|
persona=PersonaConfig(role="companion", tone="reflective", style="questioning", boundaries=[]),
|
|
memory=MemoryConfig(session_turns=20, persistent_store="", summarize_after=10),
|
|
chat=ChatConfig(streaming=True, max_response_tokens=2048, default_temperature=0.7, allow_temperature_override=True),
|
|
),
|
|
vault=VaultConfig(
|
|
path=str(vault_path),
|
|
indexing=IndexingConfig(
|
|
auto_sync=False,
|
|
auto_sync_interval_minutes=1440,
|
|
watch_fs_events=False,
|
|
file_patterns=["*.md"],
|
|
deny_dirs=[".git"],
|
|
deny_patterns=[".*"],
|
|
),
|
|
chunking_rules={},
|
|
),
|
|
rag=RagConfig(
|
|
embedding=EmbeddingConfig(
|
|
provider="ollama",
|
|
model="dummy",
|
|
base_url="http://localhost:11434",
|
|
dimensions=4,
|
|
batch_size=2,
|
|
),
|
|
vector_store=VectorStoreConfig(type="lancedb", path=str(vector_store_path)),
|
|
search=SearchConfig(
|
|
default_top_k=8,
|
|
max_top_k=20,
|
|
similarity_threshold=0.0,
|
|
hybrid_search=HybridSearchConfig(enabled=False, keyword_weight=0.3, semantic_weight=0.7),
|
|
filters=FiltersConfig(date_range_enabled=True, tag_filter_enabled=True, directory_filter_enabled=True),
|
|
),
|
|
),
|
|
model=ModelConfig(
|
|
inference=InferenceConfig(backend="llama.cpp", model_path="", context_length=8192, gpu_layers=35, batch_size=512, threads=8),
|
|
fine_tuning=FineTuningConfig(base_model="", output_dir="", lora_rank=16, lora_alpha=32, learning_rate=0.0002, batch_size=4, gradient_accumulation_steps=4, num_epochs=3, warmup_steps=100, save_steps=500, eval_steps=250, training_data_path="", validation_split=0.1),
|
|
retrain_schedule=RetrainScheduleConfig(auto_reminder=True, default_interval_days=90, reminder_channels=[]),
|
|
),
|
|
api=ApiConfig(host="127.0.0.1", port=7373, cors_origins=[], auth=AuthConfig(enabled=False)),
|
|
ui=UiConfig(web=WebConfig(enabled=True, theme="obsidian", features=WebFeaturesConfig(streaming=True, citations=True, source_preview=True)), cli=CliConfig(enabled=True, rich_output=True)),
|
|
logging=LoggingConfig(level="INFO", file="", max_size_mb=100, backup_count=5),
|
|
security=SecurityConfig(local_only=True, vault_path_traversal_check=True, sensitive_content_detection=True, sensitive_patterns=[], require_confirmation_for_external_apis=True),
|
|
)
|
|
|
|
|
|
@patch("src.rag.search.OllamaEmbedder")
|
|
@patch("src.rag.indexer.OllamaEmbedder")
|
|
def test_index_and_search_flow(mock_indexer_embedder, mock_search_embedder):
|
|
mock_embed = MagicMock()
|
|
mock_embed.embed.return_value = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0]]
|
|
mock_indexer_embedder.return_value = mock_embed
|
|
mock_search_embedder.return_value = mock_embed
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
vault = Path(tmp) / "vault"
|
|
vault.mkdir()
|
|
(vault / "note1.md").write_text("hello world", encoding="utf-8")
|
|
(vault / "note2.md").write_text("goodbye world", encoding="utf-8")
|
|
vs_path = Path(tmp) / "vectors"
|
|
config = _make_config(vault, vs_path)
|
|
store = VectorStore(uri=vs_path, dimensions=4)
|
|
indexer = Indexer(config, store)
|
|
indexer.full_index()
|
|
assert store.count() == 2
|
|
|
|
engine = SearchEngine(
|
|
vector_store=store,
|
|
embedder_base_url="http://localhost:11434",
|
|
embedder_model="dummy",
|
|
default_top_k=5,
|
|
similarity_threshold=0.0,
|
|
hybrid_search_enabled=False,
|
|
)
|
|
results = engine.search("hello")
|
|
assert len(results) >= 1
|
|
files = {r["source_file"] for r in results}
|
|
assert "note1.md" in files
|
|
```
|
|
|
|
- [ ] **Step 2: Run integration test**
|
|
|
|
Run:
|
|
```bash
|
|
pytest tests/test_integration.py -v
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add tests/test_integration.py
|
|
git commit -m "test: add end-to-end integration test for index and search"
|
|
```
|
|
|
|
---
|
|
|
|
## Plan Summary
|
|
|
|
This plan delivers a working **Vault Indexer + RAG Engine** with:
|
|
- Typed config loading with tilde expansion
|
|
- Markdown chunking (sliding window + section-based, per-directory rules)
|
|
- Ollama embedder with batching and retries
|
|
- LanceDB vector store with upsert, delete, search
|
|
- Full and incremental indexing with status tracking
|
|
- CLI commands: `index`, `sync`, `reindex`, `status`
|
|
- File system watcher with debounced auto-sync
|
|
- Search engine interface for query embedding + filtering
|
|
- Full test coverage for chunker, embedder, vector store, indexer, search, and integration
|
|
|
|
**Spec coverage check:**
|
|
- Config schema → Task 2
|
|
- Per-directory chunking rules → Task 3
|
|
- Ollama embeddings → Task 4
|
|
- LanceDB vector store → Task 5
|
|
- Full/sync/reindex/status indexing modes → Tasks 5, 6, 8
|
|
- File system watcher → Task 9
|
|
- Search with filters → Task 7
|
|
- Security (deny_dirs, deny_patterns) → Task 6
|
|
|
|
**No placeholders found.**
|
|
|
|
**Type consistency verified:** `VectorStore.TABLE_NAME`, `ChunkingRule`, `OllamaEmbedder.embed` signatures match across tasks.
|
|
|
|
---
|
|
|
|
## Execution Handoff
|
|
|
|
**Plan complete and saved to `docs/superpowers/plans/2026-04-13-personal-companion-ai-phase1.md`. Two execution options:**
|
|
|
|
**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration
|
|
|
|
**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints for review
|
|
|
|
**Which approach?**
|