All completion criteria verified — everything is green, no defects found. Final report: ## Phase 97 final verification pass — ALL GREEN **Verified (no code changes needed):** - `GET /api/docs/tree` (admin), `build_kb_tree` pure builder, `PATCH /api/folders/summary`, migration 0018 (`manually_edited`, head confirmed), generator skip/keep + `kept_manual` stat, RAG tree UI + edit affordance in `sources.js`/`index.html`/`styles.css` - `tests/e2e/test_kb_tree.py`: 8 passed — top level, drill source/folder, edit round-trip, clear, manual-desc-survives-sync, reload fallback, anonymous gate - Integration: tree shape/order/403/empty/indexed-only + PATCH update/create/root/clear/404/403/no-LLM + stat-walk equivalence (in `test_docs_api.py`); 3-field `folder_summaries=` import token preserved **Gates (exact commands):** - `uv run pytest --cov=app --cov-report=term-missing` → **2053 passed**, TOTAL coverage **99%** (>90% ✓) - `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors** - `uv run pytest tests/e2e/test_kb_tree.py -v --no-cov` → **8 passed** in isolation - 30 story/RAG-view E2E suites run **one per process**: all passed, incl. `test_ls_tree_drilldown` (agent `ls` byte-identical ✓), `test_import_documents`, `test_edit_summaries`, `test_admin_auth`, `test_kb_overview` **Completion criteria:** tree view ✓ · edit round-trip + clear ✓ · manual persists/clear resets ✓ · `ls` unchanged ✓ · pytest/coverage/lint ✓ · E2E isolation ✓ · commit — left to harness per protocol (working tree untouched, `git add/commit` not run) **Deviations:** none. **Next pending phase:** none — `todo/` contains only 97 (96 already committed).
520 lines
27 KiB
Python
520 lines
27 KiB
Python
"""SQLAlchemy models (PostgreSQL 17 + pgvector).
|
||
|
||
Data model — see ``.agents/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; ``ignore_paths`` (phase 89 — JSONB list
|
||
of normalized path prefixes, server default
|
||
``'[]'``)).
|
||
* ``saved_chats`` — owner-saved chat conversations: one row per
|
||
explicitly Saved conversation (auto-``title`` +
|
||
the ``bor.chat.v1`` message list as JSONB, phase
|
||
14 shape) — phase 50; ``/api/chat`` stays
|
||
stateless; ``share_token`` (NULL = private,
|
||
``uuid4`` = publicly readable at
|
||
``/shared/<token>``) — phase 51; ``sources_version``
|
||
(the KB generation the conversation was saved
|
||
against; 0 = the pre-counter KB, stale on the
|
||
first bump) — phase 53.
|
||
* ``sources_meta`` — single-row sources-version counter: which
|
||
generation of the knowledge base is current,
|
||
bumped exactly once per KB-changing sync so saved
|
||
chats can be marked stale (phase 53).
|
||
* ``doc_drafts`` — server-side drafts of chat answers saved as
|
||
documentation: one row per "Save as doc" action
|
||
(the long answer body lives here, never in a URL),
|
||
keyed by an unguessable ``uuid4`` ``token`` (the
|
||
edit screen's URL credential — the share-token
|
||
trust model, phase 51); ``status`` moves
|
||
``draft`` → ``pushed`` (``branch`` +
|
||
``commit_sha`` recorded) when the push endpoint
|
||
commits + pushes the file to the
|
||
``BOR_DOCS_REPO`` branch (phase 59).
|
||
* ``api_tokens`` — admin-issued access tokens: one row per
|
||
generated token, so a person handed a token can
|
||
sign in to use the app (chat, suggestion chips,
|
||
cited documents) — the ONLY content that stays
|
||
anonymous is the shared chats (phase 79).
|
||
``token_hash`` is the SHA-256 hex digest of the
|
||
full ``bor_…`` token string (the stored
|
||
credential — the plaintext exists only in the 201
|
||
create response, returned exactly once);
|
||
``revoked_at`` set = dead (live-checked on the
|
||
holder's next request), ``last_used_at`` bumped
|
||
on ``POST /api/token-auth`` (task 03 — the only
|
||
request that presents the token; the in-app gate
|
||
re-sends the cached token on every page load).
|
||
* ``ui_settings`` — single-row UI settings (phase 91): the admin
|
||
Theme tab's app name, input placeholder, footer
|
||
text and the 9 identity colors, one row
|
||
(``id = 1``); every column NULL = "use the
|
||
default" (env value for the strings, the built-in
|
||
palette for the colors — task 01).
|
||
* ``folder_summaries`` — one row per folder with ≥ 2 documents:
|
||
the sync-time ``lite`` summary the drill-down
|
||
``ls`` shows next to each folder (phase 94;
|
||
``folder_path = ""`` = the source root).
|
||
"""
|
||
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,
|
||
text,
|
||
)
|
||
from sqlalchemy.dialects.postgresql import JSONB, 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 .agents/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 SourcesMeta(Base):
|
||
"""Single-row sources-version counter (phase 53).
|
||
|
||
Exactly one row (``id = 1``, seeded by migration 0010 — the
|
||
``kb_overview`` id=1 precedent) holds the current **generation** of
|
||
the knowledge base. ``version`` is bumped exactly once per sync that
|
||
actually changed the KB (phase 53, task 02 — change-gated on
|
||
``added + updated + pruned > 0``), so it doubles as the invalidation
|
||
marker for saved chats: a ``saved_chats`` row stamped with an older
|
||
generation is *stale* — its answers predate the current index and
|
||
may be Regenerated against it (phase 53, tasks 03/05). The row is
|
||
seeded by the migration (not lazily on first bump), so
|
||
:func:`app.rag.sources_meta.current_sources_version` is a plain PK
|
||
read.
|
||
"""
|
||
|
||
__tablename__ = "sources_meta"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True, server_default="1")
|
||
#: The KB generation. 0 = the pre-counter KB (everything indexed
|
||
#: before phase 53); incremented by one per KB-changing sync.
|
||
version: Mapped[int] = mapped_column(Integer, server_default="0")
|
||
updated_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), server_default=func.now(), onupdate=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)
|
||
#: Per-source ignore paths (phase 89, A1/A4): the normalized,
|
||
#: non-empty source-relative path prefixes the owner types into the
|
||
#: box on the Sources page. A file is ignored when its
|
||
#: source-relative POSIX path starts with any entry (raw prefix —
|
||
#: no mid-path matching, no globs). Server default '[]' — every
|
||
#: pre-phase-89 row imports exactly as before.
|
||
ignore_paths: Mapped[list] = mapped_column(
|
||
JSONB, default=list, server_default=text("'[]'"), nullable=False
|
||
)
|
||
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||
|
||
|
||
class DocDraft(Base):
|
||
"""One server-side draft of a chat answer saved as documentation
|
||
(phase 59, task 01).
|
||
|
||
A long answer body must live on the **server**, never in a URL: the
|
||
"Save as doc" action POSTs the answer's raw markdown to
|
||
``POST /api/doc-drafts`` (task 02), which stores it here and hands
|
||
back an unguessable 128-bit ``uuid4`` ``token`` — the edit
|
||
screen's URL credential (``/doc-edit.html?draft=<token>``, the
|
||
share-token trust model, phase 51). ``status`` stays ``draft``
|
||
until the push endpoint (task 04) commits + pushes the file to the
|
||
``BOR_DOCS_REPO`` branch — then it is ``pushed``, with ``branch``
|
||
and ``commit_sha`` recorded (the UI's branch + sha feedback; D3:
|
||
no PR tooling — the owner opens the PR themselves).
|
||
"""
|
||
|
||
__tablename__ = "doc_drafts"
|
||
|
||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
#: The URL credential (``/doc-edit.html?draft=<token>``): an
|
||
#: unguessable 128-bit ``uuid4`` — never the row id, never
|
||
#: sequential/guessable. Unique NOT NULL: unlike the NULLable
|
||
#: ``saved_chats.share_token`` there is no "un-drafted" state, so
|
||
#: NULLs never occur (always set on create).
|
||
token: Mapped[uuid.UUID] = mapped_column(
|
||
UUID(as_uuid=True), unique=True, nullable=False, default=uuid.uuid4
|
||
)
|
||
#: The document's title. Defaults client-side to the last user
|
||
#: question (whitespace-collapsed, ≤120 chars — the chat auto-title
|
||
#: convention, phase 50); the edit screen changes anything.
|
||
title: Mapped[str] = mapped_column(Text)
|
||
#: The in-repo file path (default ``docs/<slug>.md``). Guard-railled
|
||
#: by the API layer (task 02 — repo-relative, no ``..``); the
|
||
#: column itself is plain TEXT (the ``documents.path`` precedent).
|
||
path: Mapped[str] = mapped_column(Text)
|
||
#: The markdown body — the answer's raw text (never HTML — the
|
||
#: ``bor.chat.v1`` record's ``text``), edited on the edit screen.
|
||
body: Mapped[str] = mapped_column(Text)
|
||
#: "draft" until the push endpoint commits + pushes the file, then
|
||
#: "pushed" — the domain is enforced by the API layer (the
|
||
#: ``git_sources.kind`` phase-38 precedent: plain TEXT + server
|
||
#: default, no CHECK constraint).
|
||
status: Mapped[str] = mapped_column(Text, default="draft", server_default="'draft'")
|
||
#: Set on push (task 04): the branch the commit landed on (the
|
||
#: ``BOR_DOCS_BRANCH`` name); NULL while still a draft.
|
||
branch: Mapped[str | None] = mapped_column(Text)
|
||
#: ... and the pushed branch's new HEAD sha (must equal
|
||
#: ``git rev-parse <branch>`` in the repo); NULL while still a
|
||
#: draft.
|
||
commit_sha: Mapped[str | None] = mapped_column(Text)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||
updated_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||
)
|
||
|
||
|
||
class SavedChat(Base):
|
||
"""One owner-saved chat conversation (phase 50).
|
||
|
||
Only conversations the owner explicitly **Saves** are stored —
|
||
``/api/chat`` itself stays stateless (A10, owner-locked extension
|
||
2026-08-29) and nothing is stored about a conversation that was not
|
||
saved. ``messages`` holds the exact ``bor.chat.v1`` localStorage
|
||
record shape (phase 14 — raw text, never HTML), so a saved chat
|
||
restores pixel-identical through the existing
|
||
``renderStoredMessage`` path.
|
||
"""
|
||
|
||
__tablename__ = "saved_chats"
|
||
|
||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
#: Auto-title (the first question, API-side). Plain column on purpose:
|
||
#: a future rename needs no migration.
|
||
title: Mapped[str] = mapped_column(String(500))
|
||
#: ``list[dict]`` in the ``bor.chat.v1`` record shape
|
||
#: (``{who, text, sources?, deflected?, suggestions?, thinking?,
|
||
#: tools?, stopped?}``); the API always supplies a list, so no
|
||
#: default is needed.
|
||
messages: Mapped[list] = mapped_column(JSONB)
|
||
#: Anonymous share link (phase 51): a 128-bit ``uuid4`` token; when
|
||
#: set, the chat is publicly readable at ``/shared/<token>`` without
|
||
#: any admin session, and unsharing (token → NULL) revokes it.
|
||
#: Unique — Postgres treats NULLs as distinct under a unique index
|
||
#: (the ``git_sources.path`` precedent, phase 38), so any number of
|
||
#: unshared chats coexist while two identical tokens can never exist.
|
||
share_token: Mapped[uuid.UUID | None] = mapped_column(
|
||
UUID(as_uuid=True), unique=True, nullable=True
|
||
)
|
||
#: The sources version (KB generation) the conversation was saved
|
||
#: against (phase 53): stamped by the API at save time. Existing
|
||
#: rows (saved before the counter existed) stamp ``0`` — "the
|
||
#: pre-counter KB" — and become stale on the first KB-changing sync
|
||
#: (stale = ``sources_version < current``, computed server-side by
|
||
#: the chats API, phase 53 task 03).
|
||
sources_version: Mapped[int] = mapped_column(Integer, server_default="0")
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||
updated_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||
)
|
||
|
||
|
||
class ApiToken(Base):
|
||
"""One admin-issued access token (phase 79, task 01).
|
||
|
||
The admin generates named tokens and hands them out so people can
|
||
sign in to the app and use it (chat, suggestion chips, cited
|
||
documents) — the ONLY content that stays anonymous is the shared chats
|
||
(extending the phase-16 single-admin auth; the ``require_user``
|
||
live-check and the admin token API land in tasks 02/03).
|
||
|
||
Trust model — the plaintext token (``bor_`` + 32 hex chars) exists
|
||
only in the 201 response of the create call, returned **exactly
|
||
once**; the row never carries it. The stored credential is the
|
||
SHA-256 hex digest of the **full** token string (``token_hash``):
|
||
hashing the full string, not the suffix, so a stripped prefix can
|
||
never collide. The ``saved_chats.share_token`` /
|
||
``doc_drafts.token`` lineage — but HASHED: unlike those
|
||
unguessable ``uuid4`` link tokens these are long-lived hand-out
|
||
credentials, and a leaked database must not hand anyone working
|
||
tokens.
|
||
"""
|
||
|
||
__tablename__ = "api_tokens"
|
||
|
||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
#: The hand-out name (e.g. "alice") — display-only: no index, not
|
||
#: unique (two tokens may share a label).
|
||
label: Mapped[str] = mapped_column(String(120), nullable=False)
|
||
#: The stored credential: the SHA-256 hex digest of the full
|
||
#: ``bor_…`` token string (the ``documents.content_hash``
|
||
#: String(64) precedent). Unique — the lookup is a unique-index hit
|
||
#: (``ix_api_tokens_token_hash`` — the explicit unique-index shape
|
||
#: of ``ix_saved_chats_share_token``, phase 51).
|
||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||
#: Bumped to now() on ``POST /api/token-auth`` (task 03 calls the
|
||
#: service's ``mark_used`` and commits); NULL until the token is
|
||
#: first used.
|
||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||
#: Set on revocation (task 02) — the row is dead from that moment
|
||
#: (enforced immediately on the holder's next request); NULL while
|
||
#: active.
|
||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||
|
||
|
||
class UiSettings(Base):
|
||
"""Single-row UI settings (phase 91, task 01).
|
||
|
||
The admin Theme tab (``/theme.html``, tasks 04/05) persists everything
|
||
the ``BOR_`` env vars and the retired custom-CSS theming supported in
|
||
ONE row (``id = 1`` — the single row is always id 1; ``GET`` creates
|
||
nothing, ``PUT`` upserts). The NULL = default rule (B1, owner-locked
|
||
2026-09-09): every column is nullable, and a NULL (or empty) column
|
||
means "use the default" — the env value for the three strings
|
||
(``settings.app_name`` etc.), the built-in palette
|
||
(:data:`app.core.theming.BUILTIN_COLORS`) for the 17 palette colors
|
||
(B1: no env fallback for colors) — the 9 identity colors AND the
|
||
8 semantic state colors (phase 93, task 01; B3 REVISED, owner
|
||
permission 2026-09-10, TODO.md L3 — the ``--ok-*`` / ``--err-*`` /
|
||
``--accent-*`` families join the storable palette; PLAN.md is being
|
||
redone by the owner, the decision is recorded in the phase 93
|
||
overview). :func:`app.core.theming.effective_settings` resolves the
|
||
effective 20 values both the ``GET /api/ui-settings`` and
|
||
``GET /api/config`` endpoints serve.
|
||
"""
|
||
|
||
__tablename__ = "ui_settings"
|
||
|
||
#: The single row is always id 1 (the ``kb_overview`` / ``sources_meta``
|
||
#: id=1 precedent — Python-side default; the migration carries no
|
||
#: server default because the row is created only by the PUT upsert).
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||
# --- Strings (NULL/empty = "use the env default" — B1) ---
|
||
app_name: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||
input_placeholder: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||
footer_text: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||
# --- The 9 identity colors (NULL = the built-in — B1), #rrggbb ---
|
||
bg: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||
surface: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||
ink: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||
ink_soft: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||
line: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||
grid_line: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||
brand: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||
brand_soft: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||
brand_ink: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||
# --- The 8 semantic state colors (phase 93, B3 revised; NULL = the
|
||
# built-in — B1), #rrggbb. Column names mirror the CSS variable
|
||
# names (``--ok-bg`` etc. in ``frontend/assets/styles.css``
|
||
# :root — the drift test in tests/unit/test_theming.py keeps
|
||
# the two in lockstep). ---
|
||
ok_bg: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||
ok_ink: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||
err_bg: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||
err_ink: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||
err_line: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||
accent_bg: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||
accent_ink: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||
accent_line: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||
|
||
|
||
class FolderSummary(Base):
|
||
"""One sync-time folder summary (phase 94, task 01).
|
||
|
||
``ls`` is a drill-down tree (phase 94, ``00_phase.md``): the top
|
||
level lists the synced projects, each with its stored source-root
|
||
summary; drilling into a source lists its folders, each with its
|
||
stored folder summary. This table holds those summaries:
|
||
|
||
* PK ``(source, folder_path)`` — ``source`` mirrors
|
||
``documents.source`` (String(120)); ``folder_path`` mirrors
|
||
``documents.path`` (String(1000)) and is the source-relative
|
||
folder prefix. ``folder_path = ""`` is the SOURCE ROOT — the
|
||
top-level source summary (the whole source's recursive subtree).
|
||
* Rows exist only for folders with ≥ 2 documents (recursive count
|
||
— the same set the ``ls`` count rule counts): a
|
||
single-document folder is fully described by its one file line,
|
||
so no ``lite`` burn. After a changed sync, rows whose folder
|
||
dropped below 2 documents are pruned (a pruned/renamed folder's
|
||
summary would otherwise go stale); rows for folders that still
|
||
have ≥ 2 documents persist (an unchanged folder's summary is
|
||
still true). Both rules are generator policy (``app.rag.
|
||
folder_summaries``), not schema constraints.
|
||
* ``summary`` — the 1–3 sentence plain-text description the aipi
|
||
``lite`` model wrote at sync time (``FOLDER_SUMMARY_MODE``,
|
||
``app.rag.folder_summaries`` — change-gated and fail-soft like
|
||
the KB overview: an old summary is better than none).
|
||
* ``manually_edited`` — ``true`` only while the description is the
|
||
OWNER'S words (phase 97, task 01): set ONLY by
|
||
``PATCH /api/folders/summary`` (phase 97, task 03) — the
|
||
generator never sets it. The sync-time generator (``app.rag.
|
||
folder_summaries``) SKIPS a manual row on regeneration (no
|
||
``lite`` burn on owner text — counted ``kept_manual``) and never
|
||
prunes it (owner content persists until cleared — even for a
|
||
folder below the 2-document minimum); an owner correction is
|
||
never silently rewritten (the phase-97 ``00_phase.md`` decision).
|
||
Clearing the description deletes the row — the next KB-changing
|
||
sync regenerates an AI description (the reset path).
|
||
|
||
Chat turns only READ these rows (the agent's ``ls`` output, phase
|
||
94 task 03) — generation happens at sync time only (task 02).
|
||
"""
|
||
|
||
__tablename__ = "folder_summaries"
|
||
|
||
#: Mirrors ``documents.source`` (String(120)) — PK part 1.
|
||
source: Mapped[str] = mapped_column(String(120), primary_key=True)
|
||
#: Mirrors ``documents.path`` (String(1000)) — the source-relative
|
||
#: folder prefix; ``""`` = the source root. PK part 2.
|
||
folder_path: Mapped[str] = mapped_column(String(1000), primary_key=True)
|
||
#: The lite-written 1–3 sentence description — never empty (the
|
||
#: generator validates before storing, ``app.rag.folder_summaries``).
|
||
summary: Mapped[str] = mapped_column(Text)
|
||
#: Owner-edited flag (phase 97, task 01): ``true`` only while the
|
||
#: description is the owner's words — set ONLY by
|
||
#: ``PATCH /api/folders/summary`` (phase 97, task 03); the
|
||
#: generator skips a manual row on regeneration and never prunes
|
||
#: it (the class docstring's two rules).
|
||
manually_edited: Mapped[bool] = mapped_column(
|
||
Boolean, nullable=False, server_default=text("false")
|
||
)
|
||
#: Fresh UTC stamp on every upsert (the ``kb_overview.updated_at``
|
||
#: precedent; the generator always sets it explicitly).
|
||
updated_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||
)
|