"""Source name/label/tags storage + the D2 source-name matching rule (phase 191, task 01). ``TODO.md`` L4: "Sources need a name, a label, tag(s) so you can focus the model when prompting if you have a project name or a standard name you reference things by." This module is the whole non-HTTP surface of that feature: the upsert/get/delete helpers over the ``source_labels`` table (migration 0030) and the pure matching rule the retrieval boost (task 04) consumes. **The alias contract (D1, owner-confirmed):** a label row is an ALIAS beside the derived source name — ``Document.source``, the ``source/path`` document identity, the read/grep/``show_text`` locators, the citations and the KB tree are ALL untouched by it. Renaming a source never invalidates a saved chat, a shared page or a verbatim locator. Every function here keys on the DERIVED name (``Document.source``); the display name is data, never identity. **The matching rule (D2):** a source is a SOURCE-NAME HIT when at least one :func:`app.rag.retriever.name_hit_tokens` candidate (the phase-119 class-agnostic token list, reused UNCHANGED) equals — in the :func:`app.rag.retriever._normalize_name` form, so case-insensitive and accent-folded (phase 162) — a token of the source's ``display_name``, ``label`` or any ``tags`` entry. The phase-119 digit-class rule does NOT apply to user-given names (the owner's names are not paths — "gitea" must hit even though it is digitless; the digit rule exists to keep prose tokens from matching PATHS). The one filter is :data:`app.rag.retriever.NAME_TOKEN_MIN_LEN` (4) applied to the SOURCE side too — a 2-char tag never matches. Session convention (the phase-53 ``sources_meta`` style): the write helpers only *flush* — the **caller** commits. """ from __future__ import annotations from collections.abc import Sequence from dataclasses import dataclass from typing import Final from sqlalchemy import select from sqlalchemy.orm import Session from app.models import SourceLabel from app.rag.retriever import NAME_TOKEN_MIN_LEN, _normalize_name, name_hit_tokens #: Boundary limits (phase 191 D1/D5) — the API (task 02) turns the #: ``normalize_tags`` raises into the framework's standard 422; the #: display-name / label caps are enforced there against these constants. DISPLAY_NAME_MAX_CHARS: Final = 120 LABEL_MAX_CHARS: Final = 200 TAG_MAX_CHARS: Final = 32 TAGS_MAX_COUNT: Final = 10 class _Unset: """Marker type for "field omitted" in :func:`set_label`. The partial-update shape (phase-168 D5): an OMITTED field leaves the stored value untouched, an explicit ``None`` CLEARS it — the two are indistinguishable with plain ``None`` defaults, so the sentinel owns the difference (the API passes only the fields the request carried). """ __slots__ = () _UNSET: Final = _Unset() @dataclass(frozen=True) class SourceLabelView: """Immutable read-side snapshot of one label row (D1). ``tags`` is a ``tuple`` so the view stays hashable/mutable-proof after the ORM row leaves the session; ``display_name`` / ``label`` are ``None`` when unset (never the empty string — the API trims empty submissions to NULL). """ source: str display_name: str | None label: str | None tags: tuple[str, ...] def _view(row: SourceLabel) -> SourceLabelView: return SourceLabelView( source=row.source, display_name=row.display_name, label=row.label, tags=tuple(row.tags or ()), ) def get_label(db: Session, source: str) -> SourceLabel | None: """The ORM row for *source* (one PK read); ``None`` when absent.""" return db.get(SourceLabel, source) def label_for(db: Session, source: str) -> SourceLabelView | None: """The frozen :class:`SourceLabelView` for *source* (one PK read); ``None`` when absent — the "no label row = pre-phase" contract.""" row = db.get(SourceLabel, source) return None if row is None else _view(row) def all_labels(db: Session) -> dict[str, SourceLabelView]: """Every label row as a frozen view, keyed by ``source``. ONE query (the phase-119 one-query convention): the table carries a handful of rows, so callers match in Python against it instead of issuing a query per source. Ordered by ``source`` for determinism. """ rows = db.scalars(select(SourceLabel).order_by(SourceLabel.source)).all() return {row.source: _view(row) for row in rows} def set_label( db: Session, source: str, *, display_name: str | None | _Unset = _UNSET, label: str | None | _Unset = _UNSET, tags: Sequence[str] | None | _Unset = _UNSET, ) -> SourceLabel: """Upsert the label row for *source*; return the ORM row. The partial-update shape (phase 191 D5, the phase-168 precedent): a field left at the ``_UNSET`` sentinel keeps its stored value, an explicitly passed value OVERWRITES it, an explicit ``None`` CLEARS (``tags`` clears to ``[]`` — the column is NOT NULL). A missing row is created with the passed values (omitted fields = their defaults: ``NULL`` / ``[]``). Values arrive ALREADY VALIDATED (the API layer trims/enforces the caps via :func:`normalize_tags`); this helper stores what it is given. Flush-only (the phase-53 session convention): the **caller** commits. """ row = db.get(SourceLabel, source) if row is None: row = SourceLabel( source=source, display_name=None if isinstance(display_name, _Unset) else display_name, label=None if isinstance(label, _Unset) else label, tags=[] if isinstance(tags, _Unset) or tags is None else list(tags), ) db.add(row) else: if not isinstance(display_name, _Unset): row.display_name = display_name if not isinstance(label, _Unset): row.label = label if not isinstance(tags, _Unset): row.tags = [] if tags is None else list(tags) db.flush() return row def delete_label(db: Session, source: str) -> bool: """Remove the label row for *source*; ``False`` when absent (the API's 404 shape), ``True`` when a row was deleted. Flush-only.""" row = db.get(SourceLabel, source) if row is None: return False db.delete(row) db.flush() return True def normalize_tags(raw: Sequence[str]) -> list[str]: """Normalize one tag submission (pure, the API's 422 source). * trim every entry, drop the ones that are empty afterwards (``[]`` is legal — it clears the tags); * raise :class:`ValueError` above :data:`TAGS_MAX_COUNT` entries (counted on the trimmed non-empty list, BEFORE the case-insensitive de-dupe: 11 typed tags are 11 rejected tags); * raise :class:`ValueError` when any entry exceeds :data:`TAG_MAX_CHARS` afterwards; * de-duplicate case-insensitively, preserving the FIRST case seen (``Gitea`` then ``gitea`` keeps ``Gitea``), order otherwise kept. """ cleaned = [tag.strip() for tag in raw] cleaned = [tag for tag in cleaned if tag] if len(cleaned) > TAGS_MAX_COUNT: raise ValueError(f"at most {TAGS_MAX_COUNT} tags are allowed") for tag in cleaned: if len(tag) > TAG_MAX_CHARS: raise ValueError(f"each tag must be at most {TAG_MAX_CHARS} characters") out: list[str] = [] seen: set[str] = set() for tag in cleaned: key = tag.casefold() if key in seen: continue seen.add(key) out.append(tag) return out def _source_side_tokens(view: SourceLabelView) -> set[str]: """The normalized token set of one label row (the D2 source side). Every whitespace token of ``display_name`` / ``label`` / every ``tags`` entry, folded through :func:`_normalize_name` (the single normalization BOTH sides of name matching pass through, phase 162) and kept at :data:`NAME_TOKEN_MIN_LEN` chars minimum — the 4-char floor applies to the SOURCE side too (a 2-char tag never matches). """ tokens: set[str] = set() fields: list[str] = [view.display_name or "", view.label or "", *view.tags] for field in fields: for raw in field.split(): norm = _normalize_name(raw) if len(norm) >= NAME_TOKEN_MIN_LEN: tokens.add(norm) return tokens def question_name_hits(view: SourceLabelView | None, question: str) -> bool: """The D2 rule (pure): does *question* name the source behind *view*? ``None`` (no label row) → ``False`` — the pre-phase contract. Any :func:`name_hit_tokens` candidate EQUAL (in the normalized form) to a source-side token is a hit: no digit-class rule, no prefix match (user-given names have no prose-precision problem — the phase-119 digit rule guards PATHS, not names). """ if view is None: return False candidates = name_hit_tokens(question) if not candidates: return False source_tokens = _source_side_tokens(view) return any(candidate in source_tokens for candidate in candidates) def question_name_hit_sources(db: Session, question: str) -> set[str]: """The derived source names the question names (D2) — the standalone one-query entry point (no other per-turn work needed). ONE :func:`all_labels` read + the pure rule per row (a handful of rows, matched in Python — the phase-119 one-query convention). The empty set is the common case: no label rows, or no name in the question. The chat turn does NOT call this: since task 05 it inlines the same pair against its own single ``all_labels`` read — the read that also feeds the prompt surfaces (D4) — so a turn issues ONE label query. """ return { view.source for view in all_labels(db).values() if question_name_hits(view, question) }