An existing, non-git directory is now a first-class source alongside
the git repos: one table (git_sources + kind discriminator — A13
reversible migration), one admin page, one Sync button (phase locked
decisions; the phase-35 table is extended, not duplicated). The DB is
the local-source registry — no env var for local paths;
BOR_GIT_SOURCES stays a git-only empty-table fallback.
Migration 0007 (reversible, up/down integration-tested):
git_sources.kind TEXT NOT NULL DEFAULT 'git' + ck_git_sources_kind
(kind IN ('git','local')); git_sources.path TEXT NULL +
uq_git_sources_path (mirrors 0006's uq_git_sources_url). Existing rows
read kind='git', path=NULL.
API (phase-35 contract extended, git byte-identical): POST kind=local
requires path — trimmed, ~-expanded, absolute + an existing server
directory, else 422 naming the path (fail loud at add-time); duplicate
path 409 (named); wrong field combos 422. GET rows carry kind + path
(git and env rows: path null); anonymous still 403 on every route (A10).
Sync + import_docs resolve DB git + local rows together: git →
clone_or_pull (unchanged); local → re-verified .is_dir() AT SYNC TIME
(it may have moved/deleted since add-time) — a missing dir raises
"local source missing: <path>" (sanitized) before anything imports;
one import_sources(..., prune=True) over the single combined list
(pruning covers the union). Both-empty fails loudly ("no sources
configured (git or local)"); --source still wins; the env fallback
stays git-only.
Page: second "Add a local directory" form (the same §7.4 never-stale
button + inline-error lifecycle as the git form; 422/409 details name
the path), Git/Local badges on rows (text + color, never color alone —
WCAG), updated hint (git + local together, union prune); the
anonymous sign-in gate is unchanged.
Tests: 0007 up/down; the API local-kind matrix (403/201/422/409) with
the git-kind suite green unchanged; the sync pipeline local/git/
mixed/missing against a host temp dir (the KB actually updated);
import_docs DB resolution + --source precedence. Story E2E (isolated,
deterministic across runs): add (Local badge) → missing path inline
422 naming it / duplicate 409 → the real Sync button imports the
fixture file (GET /api/docs + sentinel in its content) → file deleted
+ sync prunes it (union prune) → row removed; anonymous gate + 403s
(phase-35 regression). test_git_sources_admin.py (phase 35) green
UNCHANGED — no selector collision with the new form;
test_sync_button.py green.
Docs: README — the two managed kinds (git = clone/pull mirror; local =
direct in-place walk), add-time validation, union pruning, "the DB is
the local-source registry (no env var for local paths)";
.env.example — the env fallback is git-only.
165 lines
7.4 KiB
Python
165 lines
7.4 KiB
Python
"""SQLAlchemy models (PostgreSQL 17 + pgvector).
|
||
|
||
Data model — see ``.agent/PLAN.md`` §Data Model:
|
||
|
||
* ``documents`` — one row per imported A9 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.
|
||
* ``steering_notes`` — owner tuning notes injected into the system prompt
|
||
of every chat turn (phase 15, ``<tuning>`` section).
|
||
* ``kb_overview`` — single-row lite-generated outline of the KB's basic
|
||
categories, injected as the ``<knowledge_base>``
|
||
section of every chat turn (phase 31).
|
||
* ``git_sources`` — admin-managed source registry (git URLs + local
|
||
directories) the Sync button and import_docs
|
||
import (phase 35; ``kind`` discriminator added in
|
||
phase 38).
|
||
"""
|
||
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())
|
||
#: Lite-model summary, phase 30. Natural-language summary of the
|
||
#: document (non-markdown A9 docs only, generated at import time by the
|
||
#: aipi ``lite`` model). NULL for markdown docs, pre-phase-30 rows, and
|
||
#: the fail-soft path where summary generation failed but the document
|
||
#: was still indexed.
|
||
summary: Mapped[str | None] = mapped_column(Text, default=None)
|
||
|
||
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))
|
||
#: Summary chunk, position −1, phase 30. Marks the single extra embedded
|
||
#: chunk mirroring ``Document.summary``; default False keeps every
|
||
#: pre-phase-30 row (and ordinary content chunks) valid.
|
||
is_summary: Mapped[bool] = mapped_column(Boolean, default=False)
|
||
|
||
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
|
||
#: Lexical (FTS) candidates matched — the OR-tsquery hit count (A8). NULL
|
||
#: for pre-hybrid rows (migration 0002).
|
||
fts_hits: Mapped[int | None] = mapped_column(Integer)
|
||
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())
|
||
|
||
|
||
class SteeringNote(Base):
|
||
"""One owner tuning instruction (phase 15).
|
||
|
||
Notes are read into the system prompt of **every** chat turn as the
|
||
``<tuning>`` section (oldest first, char-budgeted — see
|
||
:func:`app.rag.prompts.build_steering_section`).
|
||
"""
|
||
|
||
__tablename__ = "steering_notes"
|
||
|
||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
note: Mapped[str] = mapped_column(Text) # trimmed, 1–2000 chars (API-enforced)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||
|
||
|
||
class KbOverview(Base):
|
||
"""Single-row, lite-generated outline of the knowledge base (phase 31).
|
||
|
||
Exactly one row (``id = 1``, enforced by the migration 0005 server
|
||
defaults) holds a plain-text outline of the KB's basic categories,
|
||
generated by the aipi ``lite`` model whenever an import changes the KB.
|
||
Chat turns only read this row (one indexed PK lookup) and inject it into
|
||
the system prompt of every turn as the ``<knowledge_base>`` section — an
|
||
empty row means the section is absent and the prompt stays
|
||
byte-identical to the pre-phase text (phase 15 convention).
|
||
"""
|
||
|
||
__tablename__ = "kb_overview"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True, server_default="1")
|
||
content: Mapped[str] = mapped_column(Text, server_default="") # the outline text
|
||
updated_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), server_default=func.now()
|
||
)
|
||
|
||
|
||
class GitSource(Base):
|
||
"""One admin-managed source (phase 35; kind discriminator, phase 38).
|
||
|
||
The UI-maintained list the Sync button (phase 32) and import_docs
|
||
(phase 28) import from. ``kind`` discriminates: ``git`` rows carry a
|
||
repo ``url`` (cloned/pulled), ``local`` rows carry an existing
|
||
directory ``path`` (walked directly). DB rows win over the
|
||
BOR_GIT_SOURCES env var (git-only fallback), which is a fallback
|
||
while this table is empty (see
|
||
app.rag.git_sources.effective_git_sources).
|
||
"""
|
||
|
||
__tablename__ = "git_sources"
|
||
|
||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
url: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||
#: Source-kind discriminator (phase 38): "git" (default) or "local"
|
||
#: — enforced by the ``ck_git_sources_kind`` CHECK constraint.
|
||
kind: Mapped[str] = mapped_column(Text, default="git", server_default="'git'")
|
||
#: Absolute directory of a ``local`` source; NULL for git rows.
|
||
#: Unique — Postgres treats NULLs as distinct under a unique index.
|
||
path: Mapped[str | None] = mapped_column(Text, unique=True)
|
||
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|