feat(systems): the catalog reaches the moment a name is minted, and gets a face (#3028, milestone 307 step 2)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 48s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Failing after 57s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 48s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Failing after 57s
CI & Build / Build & push image (push) Skipped
Step 1 found the reason the standard names never held, and it is sharper than "prose doesn't fire": the list WAS real and it WAS seeded — but only on the inception path, for a project with zero Systems. Ad-hoc create_system never consulted it, which is how Forge minted "CI and Release" and Portal minted "CI & release" after the constant already existed. This wires the vocabulary to the moment that mints a name. - services/systems.assess_system_name: the local duplicate gate AND the catalog lookup, in ONE service function both doors call. The gate lived only in the MCP tool, which is exactly how the web UI shipped without a check the agent surface enforced (#2482). REST now answers 409 with the System that already covers the area. - An `exact` catalog hit is APPLIED (mechanical — the names differ only in spelling). An `overlap` is only OFFERED, on both doors: applying a judgment call silently is how a cross-project rule surfaces in the wrong project. - canonical_systems.best_overlap is the ONE scorer behind the create-time offer and the review sweep, so the two surfaces can never name different areas for one System. It also takes the catalog the caller already holds, so the review is not an N+1. UI (folded in from step 1 — rule 27, that step shipped with no human surface): - SystemsSection: a Shared area picker on create and edit, the area on each card, and a collapsed review of proposals that appears only when there is something to decide. `exact` and `overlap` never share a style — one is mechanical, the other is the reviewer's judgment, and presenting them alike is how a wrong mapping gets waved through. - Settings → Admin → Areas: the catalog itself, showing each entry's slug, because the slug is what decides whether two names are the same area and a rename moves it. - A picker rather than a live matcher: reproducing the slug rule in TypeScript would give this feature two matchers to keep in step — the exact drift the catalog exists to end. The server stays authoritative. tests/helpers.fake_system gains canonical_id=None: an unnamed attribute is an auto-MagicMock and therefore truthy, which is the trap that helper exists for (note 2109) and a nullable FK walks straight into it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -96,6 +96,47 @@ async def find_by_name(name: str) -> CanonicalSystem | 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:
|
||||
@@ -230,27 +271,20 @@ async def propose_mappings(user_id: int, project_id: int) -> list[dict]:
|
||||
continue
|
||||
exact = by_slug.get(slug)
|
||||
if exact is not None:
|
||||
match, basis, score = exact, "exact", 1.0
|
||||
match = {"id": exact.id, "name": exact.name, "basis": "exact", "score": 1.0}
|
||||
else:
|
||||
local = _tokens(slug)
|
||||
scored = [
|
||||
(len(local & _tokens(entry.slug)) / max(len(local | _tokens(entry.slug)), 1), entry)
|
||||
for entry in catalog
|
||||
]
|
||||
# Any shared meaningful word is enough to ASK. The threshold is
|
||||
# deliberately generous because a wrong proposal costs one click
|
||||
# and a missing one costs a mapping nobody thinks to make again.
|
||||
best_score, best = max(scored, key=lambda pair: pair[0])
|
||||
if best_score <= 0:
|
||||
# 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
|
||||
match, basis, score = best, "overlap", round(best_score, 3)
|
||||
proposals.append({
|
||||
"system_id": system.id,
|
||||
"system_name": system.name,
|
||||
"canonical_id": match.id,
|
||||
"canonical_name": match.name,
|
||||
"basis": basis,
|
||||
"score": score,
|
||||
"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
|
||||
|
||||
Reference in New Issue
Block a user