CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 1m10s
CI & Build / integration (push) Successful in 1m48s
CI & Build / Python tests (push) Successful in 2m40s
CI & Build / Build & push image (push) Failing after 44s
A project's platforms decide which family ideas reach it. This step makes membership answerable from every door: - services/platforms.py: the global catalog (writes are admin-only and duplicate-gated by slug); pure marker detection; and membership reads and writes. Detection only ADDS, and only where nobody has answered. It never overrides a declared or rejected row and never removes one. - coverage: the archive scan now carries every path, and the refresh runs detection fail-open. - inception: a platforms choice (slugs, or null for unanswered). The list is the whole answer: members left out of it become rejected. - MCP: list_platforms and set_project_platforms; enter_project and get_project carry the project's platforms. - REST: /api/platforms (admin writes) and /api/projects/<id>/platforms. - UI: a platforms checklist on the inception card, a Family tab on ProjectView, and a Platforms admin tab in Settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
288 lines
11 KiB
Python
288 lines
11 KiB
Python
"""The global canonical area vocabulary, and the mapping from a project's
|
|
Systems onto it (milestone 307 step 1, decision note 3026).
|
|
|
|
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 scribe.models import async_session
|
|
from scribe.models.canonical_system import CanonicalSystem
|
|
from scribe.models.system import System
|
|
from scribe.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 (rule 78 — 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
|