Files
FabledGrimoire/src/grimoire/services/canonical_systems.py
T
bvandeusenandClaude Opus 5.5 0f5ef0be63
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
rename(547): the server is grimoire — package, image, DB defaults, backups
Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:45 -04:00

288 lines
11 KiB
Python

"""The global canonical area vocabulary, and the mapping from a project's
Systems onto it.
A `System` is per-project. Nothing outside a project can reference one, so a
rule that spans projects has no way to say "this is about CI" without chaining
itself to one project's row. `CanonicalSystem` is that join key, and it is
GLOBAL — no `user_id`, so a shared project inherits the vocabulary instead of
re-earning it.
Two rules govern everything here:
- **Associate, never rewrite.** Mapping a System sets `systems.canonical_id`
and nothing else. The local name stays whatever the project calls the area,
and `record_systems` is never touched — no record's tags move.
- **Propose, never decide.** An exact slug hit is mechanical and maps on
request; anything short of that is a PROPOSAL a human confirms. "CI &
Release" vs "CI & runners" is a judgment call, and the cost of guessing it
wrong silently is a rule surfacing in the wrong project.
Reads are open to any authenticated caller (the catalog is shared vocabulary,
not user data). Writes to the catalog itself are admin-only: a global table
that anyone can extend is how a shared vocabulary stops being shared.
"""
import logging
import re
from datetime import datetime, timezone
from sqlalchemy import select
from grimoire.models import async_session
from grimoire.models.canonical_system import CanonicalSystem
from grimoire.models.system import System
from grimoire.services import access
logger = logging.getLogger(__name__)
# Tokens that carry no meaning for matching — "&" becomes "and" before the
# split, so it would otherwise dominate the overlap score of every pair.
_NOISE_TOKENS = frozenset({"and", "the", "a", "of"})
_NON_ALNUM = re.compile(r"[^a-z0-9]+")
def canonical_slug(name: str) -> str:
"""The match key for an area name — NOT a display value.
Folds exactly the spelling differences that produced three names for one
area on the author's instance: `CI & Release`, `CI and Release` and
`CI & release` all slug to `ci-and-release`, so they map mechanically.
A real difference survives: `CI & runners` slugs to `ci-and-runners` and
goes through the proposal path where a human decides.
"""
lowered = name.strip().lower().replace("&", " and ")
return "-".join(_NON_ALNUM.sub(" ", lowered).split())
def _tokens(slug: str) -> frozenset[str]:
return frozenset(slug.split("-")) - _NOISE_TOKENS
async def _is_admin(user_id: int) -> bool:
return await access.is_instance_admin(user_id)
async def list_canonical_systems() -> list[CanonicalSystem]:
"""The whole catalog, in display order. Global — no ownership filter."""
async with async_session() as session:
result = await session.execute(
select(CanonicalSystem)
.where(CanonicalSystem.deleted_at.is_(None))
.order_by(CanonicalSystem.order_index.asc(), CanonicalSystem.name.asc())
)
return list(result.scalars().all())
async def get_canonical_system(canonical_id: int) -> CanonicalSystem | None:
async with async_session() as session:
entry = await session.get(CanonicalSystem, canonical_id)
return entry if entry is not None and entry.deleted_at is None else None
async def find_by_name(name: str) -> CanonicalSystem | None:
"""The exact-slug lookup — the mechanical half of matching."""
slug = canonical_slug(name)
if not slug:
return None
async with async_session() as session:
return await session.scalar(
select(CanonicalSystem).where(
CanonicalSystem.slug == slug,
CanonicalSystem.deleted_at.is_(None),
)
)
def _overlap(local: frozenset[str], other: frozenset[str]) -> float:
return len(local & other) / max(len(local | other), 1)
async def best_overlap(name: str, catalog: list | None = None) -> dict | None:
"""The closest catalog entry that shares a meaningful word, or None.
The ONE scorer behind both offers: the create-time suggestion and the
review surface. Two scorers would eventually disagree about which area a
name resembles, and the operator would be asked one question at create
time and a different one at review.
The threshold is any shared meaningful word, deliberately generous: a
wrong offer costs one dismissal, a missing one costs a mapping nobody
thinks to make again. Nothing here ever applies — `overlap` is always an
offer (see propose_mappings).
"""
slug = canonical_slug(name)
if not slug:
return None
local = _tokens(slug)
if not local:
return None
# A caller already holding the catalog passes it: this runs once per
# unmapped System in the review sweep, and re-reading the table each time
# would make an N+1 out of a report.
if catalog is None:
catalog = await list_canonical_systems()
best, best_score = None, 0.0
for entry in catalog:
score = _overlap(local, _tokens(entry.slug))
if score > best_score:
best, best_score = entry, score
if best is None or best_score <= 0:
return None
return {
"id": best.id, "name": best.name,
"basis": "overlap", "score": round(best_score, 3),
}
async def create_canonical_system(
user_id: int, name: str, description: str | None = None,
) -> CanonicalSystem | dict | None:
"""Add an area to the global catalog. Admin only.
Duplicate-gated on the SLUG, not the raw name, so "CI and Release" cannot
be added alongside "CI & Release" — that is the drift this table exists to
end. Returns the existing entry's id instead of creating a second one.
"""
if not await _is_admin(user_id):
return None
slug = canonical_slug(name)
if not slug:
return None
existing = await find_by_name(name)
if existing is not None:
return {
"duplicate": True,
"existing_id": existing.id,
"message": (
f"'{existing.name}' (#{existing.id}) already covers this area — "
f"both names reduce to '{slug}'. Map Systems to it, or "
"update_canonical_system if the charter needs revising."
),
}
async with async_session() as session:
highest = await session.scalar(
select(CanonicalSystem.order_index)
.order_by(CanonicalSystem.order_index.desc())
.limit(1)
)
entry = CanonicalSystem(
name=" ".join(name.split()),
slug=slug,
description=description,
order_index=(highest or 0) + 1,
)
session.add(entry)
await session.commit()
await session.refresh(entry)
return entry
async def update_canonical_system(
user_id: int, canonical_id: int, **fields: object,
) -> CanonicalSystem | None:
"""Rename or re-charter a catalog entry. Admin only.
A rename recomputes the slug — the display name and the match key must not
be allowed to disagree, or the exact-match path silently stops finding it.
"""
if not await _is_admin(user_id):
return None
allowed = {"name", "description", "order_index"}
async with async_session() as session:
entry = await session.get(CanonicalSystem, canonical_id)
if entry is None or entry.deleted_at is not None:
return None
for key, value in fields.items():
if key in allowed and value is not None:
setattr(entry, key, value)
if "name" in fields and fields["name"]:
entry.name = " ".join(str(fields["name"]).split())
entry.slug = canonical_slug(entry.name)
entry.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(entry)
return entry
async def set_system_canonical(
user_id: int, system_id: int, canonical_id: int | None,
) -> System | None:
"""Map (or unmap) one project System onto a catalog entry.
Authorised by the PROJECT, not the catalog: mapping changes the project's
row, so project write access is the right gate — never a bare owner
filter. Passing None clears the mapping.
Touches `canonical_id` and nothing else — the System's own name, charter
and record associations are left exactly as they are.
"""
if canonical_id is not None and await get_canonical_system(canonical_id) is None:
return None
async with async_session() as session:
system = await session.get(System, system_id)
if system is None or system.deleted_at is not None:
return None
if not await access.can_write_project(user_id, system.project_id):
return None
system.canonical_id = canonical_id
system.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(system)
return system
async def propose_mappings(user_id: int, project_id: int) -> list[dict]:
"""Suggest a catalog entry for each of a project's UNMAPPED Systems.
Returns proposals, never applied changes — `set_system_canonical` is the
only thing that writes. Each carries a `basis` so the reviewer knows what
they are approving:
- `exact` — the two names reduce to the same slug. Mechanical.
- `overlap` — they share a meaningful word ("CI & runners" / "CI &
Release"). A judgment call, and the reason this is a proposal at all.
A System with no plausible match simply gets no proposal: unmapped is a
perfectly good resting state, so silence here is an answer, not a gap.
"""
if not await access.can_read_project(user_id, project_id):
return []
catalog = await list_canonical_systems()
if not catalog:
return []
async with async_session() as session:
result = await session.execute(
select(System).where(
System.project_id == project_id,
System.canonical_id.is_(None),
System.deleted_at.is_(None),
).order_by(System.order_index.asc(), System.created_at.asc())
)
systems = list(result.scalars().all())
by_slug = {entry.slug: entry for entry in catalog}
proposals: list[dict] = []
for system in systems:
slug = canonical_slug(system.name)
if not slug:
continue
exact = by_slug.get(slug)
if exact is not None:
match = {"id": exact.id, "name": exact.name, "basis": "exact", "score": 1.0}
else:
# Same scorer the create-time offer uses, so the two surfaces can
# never name different areas for one System.
match = await best_overlap(system.name, catalog)
if match is None:
continue
proposals.append({
"system_id": system.id,
"system_name": system.name,
"canonical_id": match["id"],
"canonical_name": match["name"],
"basis": match["basis"],
"score": match["score"],
})
proposals.sort(key=lambda p: (-p["score"], p["system_name"]))
return proposals