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:
@@ -197,36 +197,60 @@ async def create_system(
|
||||
normalized name already exists in this project (archived included), the
|
||||
call returns {"duplicate": true, "existing_id": ...} instead of creating —
|
||||
tag records to that one, or update_system it if its charter needs work.
|
||||
|
||||
Also mapped against the GLOBAL area catalog, so the same word means the
|
||||
same thing in every project (milestone 307). A name that IS a catalog area
|
||||
up to spelling ("CI and Release" vs "CI & Release") is mapped for you and
|
||||
the response says so. A name that merely RESEMBLES one comes back with
|
||||
`canonical_suggestion` — an offer, not a decision: apply it with
|
||||
map_system_to_canonical if it really is that area, ignore it if this is a
|
||||
project-specific area. Either way the System is created; the catalog never
|
||||
blocks a name.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
norm = " ".join(name.split()).lower()
|
||||
if norm:
|
||||
try:
|
||||
existing = await systems_svc.list_systems(
|
||||
uid, project_id, include_archived=True
|
||||
)
|
||||
except Exception:
|
||||
existing = []
|
||||
for s in existing:
|
||||
if " ".join(s.name.split()).lower() == norm:
|
||||
return {
|
||||
"duplicate": True,
|
||||
"existing_id": s.id,
|
||||
"message": (
|
||||
f"System '{s.name}' (#{s.id}) already covers this area "
|
||||
"in this project. Tag records to it with system_ids, "
|
||||
"or update_system it if the charter needs revising — "
|
||||
"a second System with the same name would split the "
|
||||
"area's records across two piles."
|
||||
),
|
||||
}
|
||||
assessment = await systems_svc.assess_system_name(uid, project_id, name)
|
||||
duplicate = assessment["duplicate"]
|
||||
if duplicate:
|
||||
return {
|
||||
"duplicate": True,
|
||||
"existing_id": duplicate["id"],
|
||||
"message": (
|
||||
f"System '{duplicate['name']}' (#{duplicate['id']}) already "
|
||||
"covers this area in this project. Tag records to it with "
|
||||
"system_ids, or update_system it if the charter needs "
|
||||
"revising — a second System with the same name would split "
|
||||
"the area's records across two piles."
|
||||
),
|
||||
}
|
||||
# An exact match is mechanical, so it is applied; an overlap is a judgment
|
||||
# call, so it is only offered (see services/canonical_systems).
|
||||
canonical = assessment["canonical"]
|
||||
applied = canonical["id"] if canonical and canonical["basis"] == "exact" else None
|
||||
system = await systems_svc.create_system(
|
||||
uid, project_id=project_id, name=name,
|
||||
description=description or None, color=color or None,
|
||||
canonical_id=applied,
|
||||
)
|
||||
if system is None:
|
||||
raise ValueError(f"cannot create system in project {project_id} (no write access)")
|
||||
return system.to_dict()
|
||||
out = system.to_dict()
|
||||
if applied:
|
||||
out["canonical_note"] = (
|
||||
f"Mapped to the global area '{canonical['name']}' — the same "
|
||||
"spelling-insensitive name. Your System keeps the name you gave it."
|
||||
)
|
||||
elif canonical:
|
||||
out["canonical_suggestion"] = {
|
||||
**canonical,
|
||||
"message": (
|
||||
f"The global catalog has '{canonical['name']}', which may be "
|
||||
f"this same area. If it is, map_system_to_canonical("
|
||||
f"{system.id}, {canonical['id']}) so records and rules about "
|
||||
"this area line up across projects. If this area is specific "
|
||||
"to this project, ignore it — unmapped is a valid state."
|
||||
),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
async def list_systems(project_id: int, include_archived: bool = False) -> dict:
|
||||
|
||||
@@ -62,14 +62,38 @@ async def create_system_route(project_id: int):
|
||||
data = await request.get_json() or {}
|
||||
if not (data.get("name") or "").strip():
|
||||
return jsonify({"error": "name is required"}), 400
|
||||
# The same gate the MCP door enforces. It lived only in the tool layer
|
||||
# until now, which is exactly how the web UI shipped without gates the
|
||||
# agent surface had (#2482) — one service call, one answer (rule 33).
|
||||
assessment = await systems_svc.assess_system_name(uid, project_id, data["name"])
|
||||
duplicate = assessment["duplicate"]
|
||||
if duplicate and not data.get("force"):
|
||||
return jsonify({
|
||||
"duplicate": True,
|
||||
"existing_id": duplicate["id"],
|
||||
"error": (
|
||||
f"“{duplicate['name']}” already covers this area in this "
|
||||
"project. Tag records to it, or rename it if its charter has "
|
||||
"moved on — a second System with the same name splits the "
|
||||
"area's records across two piles."
|
||||
),
|
||||
}), 409
|
||||
canonical = assessment["canonical"]
|
||||
# Exact is mechanical and applied; overlap is a judgment call and is only
|
||||
# offered back for the form to present.
|
||||
applied = canonical["id"] if canonical and canonical["basis"] == "exact" else None
|
||||
system = await systems_svc.create_system(
|
||||
uid, project_id=project_id, name=data["name"],
|
||||
description=data.get("description"), color=data.get("color"),
|
||||
order_index=data.get("order_index", 0),
|
||||
canonical_id=data.get("canonical_id") or applied,
|
||||
)
|
||||
if system is None:
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
return jsonify(system.to_dict()), 201
|
||||
out = system.to_dict()
|
||||
if canonical and canonical["basis"] == "overlap" and not system.canonical_id:
|
||||
out["canonical_suggestion"] = canonical
|
||||
return jsonify(out), 201
|
||||
|
||||
|
||||
@systems_bp.route("/<int:project_id>/systems/<int:system_id>", methods=["GET"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,66 @@ from scribe.services import canonical_systems as canonical_systems_svc
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def local_name_key(name: str) -> str:
|
||||
"""The within-project uniqueness key: case and spacing, nothing else.
|
||||
|
||||
Deliberately weaker than `canonical_slug`. This one answers "is this the
|
||||
same System I already have here", where the operator's own spelling is the
|
||||
thing being compared; the canonical slug answers "is this the same AREA as
|
||||
some other project's System", where spelling is exactly what must be
|
||||
ignored.
|
||||
"""
|
||||
return " ".join(name.split()).lower()
|
||||
|
||||
|
||||
async def assess_system_name(user_id: int, project_id: int, name: str) -> dict:
|
||||
"""What BOTH doors must know before minting a System name (milestone 307).
|
||||
|
||||
Lived in the MCP tool alone until now, which is how the web UI shipped
|
||||
without a gate the agent surface enforced (#2482). One service function, so
|
||||
the two doors cannot answer the same question differently (rule 33).
|
||||
|
||||
Returns `{"duplicate": …|None, "canonical": …|None}`:
|
||||
|
||||
- `duplicate` — this project already has a System by that name. A hard stop
|
||||
for the caller: a second one splits the area's records across two piles.
|
||||
- `canonical` — the global catalog covers this area, with a `basis`.
|
||||
`exact` is mechanical and safe to apply on the spot; `overlap` is a
|
||||
judgment call and must be OFFERED, never applied. Neither ever blocks:
|
||||
an unmatched name is a project-specific area, which is legitimate.
|
||||
|
||||
Fails open on both arms — a naming aid must never break a create.
|
||||
"""
|
||||
out: dict = {"duplicate": None, "canonical": None}
|
||||
key = local_name_key(name)
|
||||
if not key:
|
||||
return out
|
||||
try:
|
||||
for existing in await list_systems(user_id, project_id, include_archived=True):
|
||||
if local_name_key(existing.name) == key:
|
||||
out["duplicate"] = {"id": existing.id, "name": existing.name}
|
||||
return out
|
||||
except Exception:
|
||||
logger.debug("system name assessment: local scan failed", exc_info=True)
|
||||
return out
|
||||
try:
|
||||
exact = await canonical_systems_svc.find_by_name(name)
|
||||
if exact is not None:
|
||||
out["canonical"] = {
|
||||
"id": exact.id, "name": exact.name, "basis": "exact",
|
||||
}
|
||||
return out
|
||||
# No exact hit: fall back to the same overlap scoring the review
|
||||
# surface uses, so a create-time offer and a later proposal never
|
||||
# disagree about which area a name resembles.
|
||||
near = await canonical_systems_svc.best_overlap(name)
|
||||
if near is not None:
|
||||
out["canonical"] = near
|
||||
except Exception:
|
||||
logger.debug("system name assessment: catalog lookup failed", exc_info=True)
|
||||
return out
|
||||
|
||||
|
||||
async def standard_systems() -> list[tuple[str, str]]:
|
||||
"""The standard cross-project vocabulary (#2798) as (name, charter) pairs.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user