feat: scaffold Brain of Reese — FastAPI RAG chat over Postgres 17 + pgvector

Foundation (phase 01, verified):
- FastAPI app: /api/health, /api/suggestions, /api/chat (placeholder),
  static frontend served locally (no CDN)
- Postgres 17 + pgvector via db/Containerfile + compose.yaml
  (podman compose up -d db), Alembic initial migration (documents,
  chunks with vector(768), query_log)
- LLM client targeting https://aipi.reeseapps.com/v1 (turbo/embed);
  scripts/llm_probe.py verified models + 768-dim embeddings live
- Conditional debugpy: imported only when DEBUGPY=1 (attach on demand,
  :5678); logging config for clean single-line logs
- Frontend shell: mobile-first chat + Sources pages, tokens, a11y baselines
- Tests: 24 unit+integration (99% coverage on app/), ruff + pyright clean,
  Playwright smoke E2E (3 tests) against a deterministic mock LLM
- Planning: .agent/PLAN.md (architecture + LOCKED decisions), AGENTS.md,
  6 user stories, 7 phase files (one story / one phase / one Playwright
  suite each)
This commit is contained in:
2026-08-21 13:42:21 -04:00
commit 022da8e2bc
63 changed files with 5225 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Brain of Reese application package."""
__version__ = "0.1.0"
+1
View File
@@ -0,0 +1 @@
"""FastAPI routers."""
+29
View File
@@ -0,0 +1,29 @@
"""POST /api/chat — placeholder (phase 01).
Phase 03 replaces this with the real RAG pipeline and SSE streaming
(PLAN §4 contract: ``delta`` events + final ``done``). The placeholder
keeps the same JSON shape the frontend already consumes, so the UI round-trip
is exercised end-to-end from day one.
"""
from __future__ import annotations
from fastapi import APIRouter
from app.schemas import ChatRequest
router = APIRouter()
@router.post("/chat")
async def chat(request: ChatRequest) -> dict[str, object]:
"""Placeholder answer — no LLM, no DB."""
return {
"ok": True,
"answer": (
"Hey! My neurons are still wiring up — the real Brain "
"(RAG over your docs, powered by aipi) lands in the next "
"phases. Try me again soon! 🧠"
),
"deflected": False,
"sources": [],
}
+21
View File
@@ -0,0 +1,21 @@
"""Health & readiness endpoint."""
from __future__ import annotations
from fastapi import APIRouter
from app.config import get_settings
from app.db import db_available
from app.schemas import HealthResponse
router = APIRouter(tags=["health"])
@router.get("/health", response_model=HealthResponse)
def health() -> HealthResponse:
settings = get_settings()
return HealthResponse(
status="ok",
db="up" if db_available() else "down",
version=settings.app_version,
environment=settings.environment,
)
+14
View File
@@ -0,0 +1,14 @@
"""Suggested-question endpoint (drives the onboarding chips in the UI)."""
from __future__ import annotations
from fastapi import APIRouter
from app.config import get_settings
from app.schemas import SuggestionList
router = APIRouter(tags=["chat"])
@router.get("/suggestions", response_model=SuggestionList)
def suggestions() -> SuggestionList:
return SuggestionList(suggestions=get_settings().suggestions)
+64
View File
@@ -0,0 +1,64 @@
"""Application settings.
Every setting can be overridden with an environment variable prefixed
``BOR_`` (or a local gitignored ``.env`` file — see ``.env.example``).
"""
from __future__ import annotations
import os
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
env_prefix="BOR_",
extra="ignore",
)
# --- App ---
app_name: str = "Brain of Reese"
app_version: str = "0.1.0"
environment: str = "development"
log_level: str = "INFO"
static_dir: str = "frontend"
# --- Database (PostgreSQL 17 + pgvector) ---
database_url: str = "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese"
# --- LLM (self-hosted, OpenAI-compatible "aipi" endpoint) ---
llm_base_url: str = "https://aipi.reeseapps.com/v1"
llm_api_key: str = ""
llm_chat_model: str = "turbo"
llm_embed_model: str = "embed"
# --- RAG tuning ---
embedding_dim: int = 768 # verified against aipi /v1 (embed model)
top_k_chunks: int = 4
top_n_docs: int = 2
relevance_threshold: float = 0.30
max_context_chars: int = 24_000
chunk_target_chars: int = 2_000
chunk_overlap_chars: int = 200
embed_batch_size: int = 16
# Suggested questions (onboarding + empty state).
suggestions: list[str] = [
"How is my Kubernetes cluster set up?",
"What's my backup strategy?",
"How do I deploy a new service?",
"What's currently running in the homelab?",
]
@property
def effective_api_key(self) -> str:
"""API key for aipi: explicit setting, then $AIPI_KEY, then a placeholder."""
return self.llm_api_key or os.environ.get("AIPI_KEY", "") or "not-needed"
@lru_cache
def get_settings() -> Settings:
return Settings()
+1
View File
@@ -0,0 +1 @@
"""Core utilities (debugging, logging)."""
+64
View File
@@ -0,0 +1,64 @@
"""Conditional remote debugging via ``debugpy``.
**Default (``DEBUGPY`` unset or ``0``):** ``debugpy`` is *never imported* —
zero overhead, production-safe.
**``DEBUGPY=1``:** imports ``debugpy`` and opens a *non-blocking* listener on
``0.0.0.0:5678`` (override with ``DEBUGPY_PORT``). The application continues
immediately; an IDE (VS Code / PyCharm) attaches on demand at any time.
Usage: call :func:`configure_debugging` once, as early as possible in the
entrypoint (see ``app/main.py``).
"""
from __future__ import annotations
import contextlib
import logging
import os
from typing import Any
logger = logging.getLogger("app.debugging")
_listener: Any = None # debugpy.listen() result, when enabled
def _port() -> int:
try:
return int(os.environ.get("DEBUGPY_PORT", "5678"))
except ValueError:
return 5678
def _is_enabled() -> bool:
return os.environ.get("DEBUGPY", "0").strip() == "1"
def configure_debugging() -> bool:
"""Enable debugpy if and only if ``DEBUGPY=1``.
Returns ``True`` when the listener was started.
"""
if not _is_enabled():
return False
global _listener
import debugpy # imported ONLY when explicitly enabled — no overhead otherwise
port = _port()
_listener = debugpy.listen(("0.0.0.0", port))
logger.warning(
"debugpy: remote debugging ENABLED, listening on 0.0.0.0:%d (attach on demand)", port
)
return True
def shutdown_debugpy() -> None:
"""Stop the debugpy listener (used by tests and graceful shutdown)."""
global _listener
if _listener is not None:
sock = getattr(_listener, "local_socket", None)
if sock is not None:
with contextlib.suppress(OSError):
sock.close()
_listener = None
+26
View File
@@ -0,0 +1,26 @@
"""Logging configuration.
Human-readable, timestamped, single-line records on stdout. Every
request-critical operation (retrieval, LLM calls, imports) logs key=value
context at INFO level so a user is never left wondering what the system is
doing — this pairs with the UI's loading/progress feedback (see PLAN §UI/UX).
"""
from __future__ import annotations
import logging
import sys
_FORMAT = "%(asctime)s %(levelname)-8s %(name)s :: %(message)s"
_DATEFMT = "%Y-%m-%d %H:%M:%S"
def configure_logging(level: str = "INFO") -> None:
root = logging.getLogger()
root.setLevel(level.upper())
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter(_FORMAT, _DATEFMT))
root.handlers = [handler]
# Keep third-party noise down while our own loggers stay verbose.
for noisy in ("httpx", "httpcore", "openai", "urllib3"):
logging.getLogger(noisy).setLevel(logging.WARNING)
+41
View File
@@ -0,0 +1,41 @@
"""Database engine & session factory (PostgreSQL 17 + pgvector).
The engine is created lazily: importing this module never opens a
connection, so the app boots (and ``/api/health`` reports) even when the
database is momentarily unavailable.
"""
from __future__ import annotations
from collections.abc import Generator
from sqlalchemy import create_engine, text
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import get_settings
class Base(DeclarativeBase):
pass
engine = create_engine(get_settings().database_url, pool_pre_ping=True, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False, future=True)
def get_db() -> Generator[Session, None, None]:
"""FastAPI dependency yielding a database session."""
db = SessionLocal()
try:
yield db
finally:
db.close()
def db_available() -> bool:
"""Cheap liveness probe used by ``/api/health``."""
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
return True
except Exception:
return False
+47
View File
@@ -0,0 +1,47 @@
"""Brain of Reese — application entrypoint.
Boots logging + conditional debugpy, then creates the FastAPI app:
API routes first (so they win over the catch-all), and the static frontend
mounted last. No CDN: everything the browser needs is served by this
process from local files (see PLAN §UI/UX — No External Dependencies).
"""
from __future__ import annotations
import logging
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.api.chat import router as chat_router
from app.api.health import router as health_router
from app.api.suggestions import router as suggestions_router
from app.config import get_settings
from app.core.debugging import configure_debugging
from app.core.logging import configure_logging
configure_logging()
configure_debugging()
settings = get_settings()
logger = logging.getLogger("app")
def create_app() -> FastAPI:
app = FastAPI(title=settings.app_name, version=settings.app_version)
# API routes first so they take precedence over the catch-all static mount.
app.include_router(health_router, prefix="/api")
app.include_router(suggestions_router, prefix="/api")
app.include_router(chat_router, prefix="/api")
static_dir = Path(settings.static_dir).resolve()
if static_dir.is_dir():
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
else:
logger.warning("static dir %s not found — serving API only", static_dir)
return app
app = create_app()
+81
View File
@@ -0,0 +1,81 @@
"""SQLAlchemy models (PostgreSQL 17 + pgvector).
Data model — see ``.agent/PLAN.md`` §Data Model:
* ``documents`` — one row per ``*.md`` file (full content, path, sha256 hash).
* ``chunks`` — retrieval units; each chunk points at its parent document
via ``document_id``. This is how an embedding maps back to
a document path (the "feed the whole document" requirement).
* ``query_log`` — observability: every question, its retrieval score, the
deflection decision, and latency.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from pgvector.sqlalchemy import Vector
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.config import get_settings
from app.db import Base
# Single source of truth for the vector column size (see .agent/PLAN.md A6).
EMBEDDING_DIM: int = get_settings().embedding_dim
class Document(Base):
__tablename__ = "documents"
__table_args__ = (UniqueConstraint("source", "path", name="uq_documents_source_path"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
source: Mapped[str] = mapped_column(String(120), index=True) # e.g. "Homelab"
path: Mapped[str] = mapped_column(String(1000), index=True) # relative to source dir
full_path: Mapped[str] = mapped_column(String(2000)) # absolute path at import time
title: Mapped[str] = mapped_column(String(500))
content: Mapped[str] = mapped_column(Text) # full markdown — the RAG context
content_hash: Mapped[str] = mapped_column(String(64), index=True) # sha256 for change detection
indexed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
chunks: Mapped[list[Chunk]] = relationship(
back_populates="document", cascade="all, delete-orphan"
)
class Chunk(Base):
__tablename__ = "chunks"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
document_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("documents.id", ondelete="CASCADE"), index=True
)
position: Mapped[int] = mapped_column(Integer)
content: Mapped[str] = mapped_column(Text)
embedding: Mapped[list[float] | None] = mapped_column(Vector(EMBEDDING_DIM))
document: Mapped[Document] = relationship(back_populates="chunks")
class QueryLog(Base):
__tablename__ = "query_log"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
question: Mapped[str] = mapped_column(Text)
top_score: Mapped[float] = mapped_column(Float, default=0.0) # best cosine similarity
chunk_hits: Mapped[int] = mapped_column(Integer, default=0)
deflected: Mapped[bool] = mapped_column(Boolean, default=False) # True = honest "no idea"
sources: Mapped[str] = mapped_column(Text, default="") # comma-joined source paths
latency_ms: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+45
View File
@@ -0,0 +1,45 @@
"""Pydantic request/response schemas (API contract)."""
from __future__ import annotations
from pydantic import BaseModel, Field
class HealthResponse(BaseModel):
status: str
db: str
version: str
environment: str
class SuggestionList(BaseModel):
suggestions: list[str]
class ChatRequest(BaseModel):
message: str = Field(min_length=1, max_length=4000)
class SourceRef(BaseModel):
source: str
path: str
title: str
class ChatDoneEvent(BaseModel):
"""Final SSE event of a chat turn: metadata for the finished answer."""
type: str = "done"
deflected: bool
sources: list[SourceRef]
suggestions: list[str] = []
class DocSummary(BaseModel):
"""One indexed document as shown on the Sources page / API."""
id: str
source: str
path: str
title: str
chunks: int
indexed_at: str