Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion services/api/run_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"test_ask", "test_verification", "test_webhooks", "test_operate_capital", "test_payroll_drawings", "test_assistant_itb", "test_construction_depth", "test_distribution", "test_e57", "test_empty_project", "test_metrics", "test_metrics_auth", "test_licensing", "test_revit_bridge", "test_precon", "test_specs", "test_feasibility", "test_clash_import", "test_clash_intel", "test_layout", "test_loads", "test_verified_progress", "test_element_records", "test_securities_bridge", "test_imports", "test_search_alerts", "test_attachments",
# previously not wired into the gate (glob would have caught these) — now covered:
"test_analytics", "test_discipline", "test_gbxml", "test_review", "test_interop",
"test_module_config", "test_module_schema", "test_module_tables", "test_module_filters", "test_module_fields", "test_throttle", "test_route_order",
"test_module_config", "test_module_schema", "test_ref_backfill", "test_module_tables", "test_module_filters", "test_module_fields", "test_throttle", "test_route_order",
# R23-PREFAB-KIT — the kit join + its register routes:
"test_prefab_kit", "test_prefab_route",
# Tier-1 competitive upgrades:
Expand Down
175 changes: 175 additions & 0 deletions services/api/src/aec_api/ref_backfill.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""MOD-BACKFILL — populate the additive text+reference pairs from the text already stored.

The field sweep found 74 text fields naming a concept that already had its own register (`vendor`,
`location`, `spec_section`, `system`, `inspector`), and added 54 reference fields **beside** them
rather than converting in place — because a converted field's legacy string would have rendered as a
clickable link labelled with its first eight characters, across 56 modules, with every gate green.

That left the references empty. This fills them in, and the whole design is about **which matches it
refuses to make**.

**A WRONG AUTO-LINK IS WORSE THAN AN EMPTY ONE**, and not by a little. An empty reference is visibly
empty: somebody sees a blank and fills it. A wrong one is a link that resolves, opens a real record,
and shows a plausible name — so it is never questioned, and it silently attributes an RFI to the wrong
subcontractor or a defect to the wrong room. Nothing downstream can tell the difference, because the
value is structurally perfect. This is the same asymmetry as every other defect this codebase has
chased: the dangerous output is the plausible one, not the missing one.

So the matcher is deliberately timid:

* **Exact, after normalisation only.** Case, surrounding whitespace, internal whitespace runs and a
trailing legal suffix (`Inc`, `LLC`, `Ltd`, `Co`, `Corp`) are normalised away. Nothing else — no
fuzzy distance, no token overlap, no prefix matching. "Acme Electrical" and "Acme Electric" are
different companies and this will not pretend otherwise.
* **Unique or nothing.** Two candidate records matching one string is not a 50/50 guess to be broken
by `created_at` or by id order; it is a question for a human. Ambiguity is reported, never resolved.
* **Never overwrite.** A reference that already has a value is left alone even if it disagrees with the
text, because somebody may have set it by hand and the hand-set one is the better evidence.
* **Dry run by default.** `apply=False` returns exactly what it would do and changes nothing, so the
report can be read before anything is written.

The counterpart to timidity is that every skip is REPORTED with its reason. A backfill that silently
does 60% of the work and says "done" leaves the other 40% invisible, which is worse than one that
does 60% and names the rest.
"""
from __future__ import annotations

import re
from typing import Any

from sqlalchemy.orm import Session

from .modules_query import list_records
from .modules_registry import REGISTRY

#: The suffixes the sweep used when adding a reference beside its text twin.
PAIR_SUFFIXES = ("_company", "_loc", "_spec", "_system", "_contact", "_package")

#: Trailing company-form tokens that carry no identity — "Acme Inc" and "Acme" are one firm.
_LEGAL_SUFFIX = re.compile(
r"[\s,]+(inc|inc\.|llc|l\.l\.c\.|ltd|ltd\.|limited|co|co\.|corp|corp\.|corporation|company|"
r"plc|lp|llp|pllc|gmbh|pty|sa|nv|bv)$", re.I)
Comment on lines +48 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

5. Suffix stripping overly broad 🐞 Bug ≡ Correctness

normalize() strips a trailing "company" token as a legal suffix, which can conflate real names where
“Company” is part of the identity (e.g., “The Company” vs “The”). That increases the chance of
creating a wrong but plausible unique match.
Agent Prompt
### Issue description
`_LEGAL_SUFFIX` includes `company`, so `normalize("Acme Company") == normalize("Acme")`. Because backfill links on *exact normalized equality*, this broadens the match surface and can create incorrect unique matches.

### Issue Context
This backfill explicitly optimizes for “refuse rather than guess”; broad normalization rules increase guessing.

### Fix Focus Areas
- services/api/src/aec_api/ref_backfill.py[48-69]

### Suggested fix
- Remove `company` from `_LEGAL_SUFFIX` (keeping `co`/`corp`/`inc`/etc.), **or**
- Add a stricter rule (e.g., only strip `company` when it appears as part of a known legal form pattern), **and**
- Add a focused test case demonstrating the desired behavior for names ending in "Company" so future changes don’t reintroduce accidental conflation.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



def normalize(s: Any) -> str:
"""The one normalisation both sides go through. Anything this does not fold is a real difference.

Deliberately shallow: lowercase, collapse whitespace, drop a single trailing legal suffix, strip
surrounding punctuation. It does NOT strip internal punctuation ("J&J" vs "J and J"), expand
abbreviations ("Bldg" vs "Building") or do edit-distance — each of those turns a refusal into a
guess, and a guess here produces a link that looks right.
"""
t = re.sub(r"\s+", " ", str(s or "")).strip().strip(".,;:-–—").strip()
if not t:
return ""
prev = None
while prev != t: # "Acme Co., Inc." -> "Acme"
prev = t
t = _LEGAL_SUFFIX.sub("", t).strip().strip(".,;:-–—").strip()
return t.lower()
Comment on lines +49 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. normalize() regex lacks caps 📜 Skill insight ⛨ Security

normalize() applies regexes with unbounded quantifiers to free-text input without capping input
length before matching/substitution. This violates the ReDoS prevention requirement for bounded
regex quantifiers and capped scan length.
Agent Prompt
## Issue description
`services/api/src/aec_api/ref_backfill.py` uses regexes with unbounded quantifiers (e.g., `\s+`, `[\s,]+`) and does not cap the input length before applying them in `normalize()`, which violates the ReDoS compliance rule.

## Issue Context
Even if the current patterns are relatively simple, the compliance rule requires both bounded quantifiers and a maximum input length (e.g., `text[:20_000]`) before regex processing.

## Fix Focus Areas
- services/api/src/aec_api/ref_backfill.py[49-69]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



def pairs_for(module_key: str) -> list[tuple[str, str, str]]:
"""[(text_field, reference_field, target_module)] for one module, from its declared fields."""
mod = REGISTRY.get(module_key) or {}
by = {f.get("name"): f for f in mod.get("fields", []) if f.get("name")}
out: list[tuple[str, str, str]] = []
for name, f in by.items():
if f.get("type") != "reference" or not f.get("module"):
continue
for suf in PAIR_SUFFIXES:
if name.endswith(suf):
stem = name[: -len(suf)]
twin = by.get(stem)
if twin and twin.get("type") in ("text", "textarea"):
out.append((stem, name, f["module"]))
break
return sorted(out)


def _index(db: Session, project_id: str, target: str) -> tuple[dict[str, list[str]], int]:
"""normalised title -> [record ids] for a target module, plus how many records were indexed.

A LIST, not a single id: collisions are the thing this exists to detect, and a dict keyed by title
would silently keep the last one — which is exactly the quiet wrong answer the whole module is
written to avoid.
"""
mod = REGISTRY.get(target) or {}
tf = mod.get("title_field")
idx: dict[str, list[str]] = {}
rows = list_records(db, target, project_id, limit=5000)
for r in rows:
data = r.get("data") or {}
for candidate in (data.get(tf) if tf else None, r.get("ref")):
key = normalize(candidate)
if key:
idx.setdefault(key, []).append(r["id"])
return idx, len(rows)


def backfill(db: Session, project_id: str, *, module_keys: list[str] | None = None,
apply: bool = False) -> dict[str, Any]:
"""Fill empty reference fields from their text twins. Returns a full report either way.

`apply=False` (the default) changes nothing — the report is identical to what an apply would do,
so it can be read first. Nothing is ever overwritten and no ambiguous match is ever resolved.
"""
from . import modules as me # local: the engine imports this module's peers

keys = module_keys or sorted(REGISTRY)
linked: list[dict[str, str]] = []
skipped: list[dict[str, str]] = []
indexes: dict[str, tuple[dict[str, list[str]], int]] = {}
scanned = 0

for key in keys:
prs = pairs_for(key)
if not prs:
continue
records = list_records(db, key, project_id, limit=5000)
scanned += len(records)
for text_field, ref_field, target in prs:
if target not in indexes:
indexes[target] = _index(db, project_id, target)
idx, target_n = indexes[target]
for r in records:
data = r.get("data") or {}
raw = data.get(text_field)
if data.get(ref_field):
continue # never overwrite a hand-set link
norm = normalize(raw)
if not norm:
continue # nothing to match on; not a skip worth reporting
hits = idx.get(norm) or []
base = {"module": key, "record": r["id"], "ref": r.get("ref") or "",
"field": ref_field, "text": str(raw), "target": target}
if len(hits) == 1:
linked.append({**base, "linked_to": hits[0]})
if apply:
# `party=None`: a backfill is not a workflow actor. It writes one reference
# field and must not be able to advance a state or satisfy a party gate —
# the actor string keeps it identifiable in the audit trail.
me.update_record(db, key, project_id, r["id"], {ref_field: hits[0]},
"system:backfill", None)
elif len(hits) > 1:
# Two records answer to one name. Breaking the tie by id or date would be a coin
# flip wearing a suit — it is a question for a human, so it is reported as one.
skipped.append({**base, "reason": "ambiguous",
"detail": f"{len(hits)} records in {target} share this name"})
else:
skipped.append({**base, "reason": "no match",
"detail": f"no {target} record is named this "
f"({target_n} indexed)"})
return {
"applied": bool(apply),
"records_scanned": scanned,
"linked": linked,
"skipped": skipped,
"summary": {"linked": len(linked),
"ambiguous": sum(1 for s in skipped if s["reason"] == "ambiguous"),
"no_match": sum(1 for s in skipped if s["reason"] == "no match")},
"note": ("Exact match after normalisation only, unique matches only, existing values never "
"overwritten. A wrong link resolves, opens a real record and shows a plausible name, "
"so it is never questioned — an empty reference is visibly empty and gets filled. "
"Every skip is listed with its reason rather than left as a silent shortfall."),
}
22 changes: 21 additions & 1 deletion services/api/src/aec_api/routers/modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pydantic import BaseModel
from sqlalchemy.orm import Session

from .. import ai, audit, mailer, rbac, rooms
from .. import ai, audit, mailer, rbac, ref_backfill, rooms
from .. import modules as mod_engine
from .. import sync as sync_engine
from ..db import get_db
Expand Down Expand Up @@ -62,6 +62,26 @@ def list_modules():
]


@router.post("/projects/{pid}/modules/backfill-references")
def backfill_references(pid: str, module: str | None = None, apply: bool = False,
db: Session = Depends(get_db),
_: str = Depends(require_role("admin"))):
"""MOD-BACKFILL — fill empty reference fields from the text twin already on the record.

The field sweep added 54 references BESIDE their text fields rather than converting in place;
this populates them. Exact match after normalisation, unique matches only, existing values never
overwritten — and **`apply` defaults to False**, so the default call is a report.

A wrong auto-link is worse than an empty one: it resolves, opens a real record and shows a
plausible name, so nobody questions it. An empty reference is visibly empty and gets filled by
the next person to look. Every skip is returned with its reason rather than left as a silent
shortfall.

`admin` because it writes across every register in the project in one call.
"""
return ref_backfill.backfill(db, pid, module_keys=[module] if module else None, apply=apply)

Comment on lines +65 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Unknown module returns success 🐞 Bug ☼ Reliability

The new /modules/backfill-references endpoint accepts any module string and passes it through;
unknown module keys are silently skipped and return HTTP 200 with an empty report. This can make an
operator believe a backfill ran (or was safely reviewed) when nothing happened.
Agent Prompt
### Issue description
When the caller provides `module=<typo>`, `backfill()` will process that key, but `pairs_for()` returns an empty list for unknown modules and the tool returns a normal-looking 200 response with no explicit error.

### Issue Context
The endpoint is an admin operational tool meant to be "refusal-forward"; a typo should fail loudly.

### Fix Focus Areas
- services/api/src/aec_api/routers/modules.py[65-83]
- services/api/src/aec_api/ref_backfill.py[72-88]
- services/api/src/aec_api/ref_backfill.py[119-129]

### Suggested fix
- In the router, if `module` is provided and `module not in mod_engine.REGISTRY` (or `ref_backfill.REGISTRY`), raise `HTTPException(404, "unknown module ...")`.
- Alternatively (or additionally), in `backfill()` validate all `module_keys` up front and include an explicit `unknown_module` entry in the report (but do not silently skip).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


@router.get("/rooms")
def list_rooms():
"""R26 — the five-room spine, and the allocation of every module to exactly one room.
Expand Down
Loading