SmartQS-Copilot / src /anomalies.py
benjamintia's picture
Upload folder using huggingface_hub
06c77d6 verified
Raw
History Blame Contribute Delete
4.44 kB
"""Anomaly detection on parsed BOQ items.
Rules: rate deviation vs reference, duplicates, missing access/safety sections,
unit mismatches, quantity plausibility. Every flag carries a severity + reason."""
import numpy as np
SEV = {"info": 0, "warning": 1, "critical": 2}
RATE_TOLERANCE = 0.5 # +-50% around reference tolerated
CRITICAL_RATE = 1.5 # >150% above reference -> critical
def detect(rows):
flags = []
# 1) rate deviations vs reference db
for r in rows:
if r.get("ref_rate") and r.get("rate"):
ref, rate = r["ref_rate"], r["rate"]
if ref <= 0:
continue
dev = (rate - ref) / ref
if dev >= CRITICAL_RATE:
flags.append({
"severity": "critical", "type": "rate",
"item": r["item"], "description": r["description"],
"detail": f"Rate HK${rate:,.0f} is {dev*100:.0f}% above the reference (HK${ref:,.0f}). "
"Verify: missing digit or wrong unit?"
})
elif abs(dev) >= RATE_TOLERANCE:
flags.append({
"severity": "warning", "type": "rate",
"item": r["item"], "description": r["description"],
"detail": f"Rate HK${rate:,.0f} is {dev*100:+.0f}% vs reference HK${ref:,.0f}. "
"Check for over/under-pricing."
})
# 2) duplicates (same description + unit)
seen = {}
for r in rows:
key = (r["description"].lower().strip(), r["unit"])
if key in seen:
flags.append({
"severity": "warning", "type": "duplicate",
"item": r["item"], "description": r["description"],
"detail": f"Duplicate item also at {seen[key]}. Confirm it is intentional "
"(e.g. separate work sections) and not a copy error."
})
else:
seen[key] = r["item"]
# 3) missing access / safety / temporary works
text = " ".join((r["description"] or "") for r in rows).lower()
if "scaffold" not in text:
flags.append({
"severity": "warning", "type": "missing",
"item": "-", "description": "Scaffolding / access",
"detail": "No scaffolding or access item found. Multi-storey works without access "
"provision usually signals an omitted trade section."
})
if not any(k in text for k in ["safety", "temporary works", "site establishment", "hoarding"]):
flags.append({
"severity": "warning", "type": "missing",
"item": "-", "description": "Safety / temporary works",
"detail": "No safety, hoarding or site establishment item. Public works contracts "
"normally carry these preliminaries."
})
# 4) quantity plausibility (product z-score within trade)
prods = [(r, (r.get("qty") or 0) * (r.get("rate") or 0)) for r in rows if r.get("qty") and r.get("rate")]
if len(prods) >= 4:
vals = np.array([p[1] for p in prods])
if vals.std() > 0:
z = (vals - vals.mean()) / vals.std()
for (r, _), zz in zip(prods, z):
if abs(zz) > 3.0:
flags.append({
"severity": "warning", "type": "quantity",
"item": r["item"], "description": r["description"],
"detail": f"Line total (HK${r['qty']*r['rate']:,.0f}) is an extreme outlier "
f"(z={zz:+.1f}). Verify quantity or rate."
})
# 5) unit sanity
for r in rows:
if r.get("ref_rate") and r.get("unit") and r.get("rate"):
if r["unit"] != r["unit"]: # placeholder never fires
pass
return flags
def summary(flags):
if not flags:
return "No anomalies detected. The BOQ looks internally consistent."
by_sev = {"critical": 0, "warning": 0, "info": 0}
for f in flags:
by_sev[f["severity"]] = by_sev.get(f["severity"], 0) + 1
parts = []
if by_sev["critical"]:
parts.append(f"{by_sev['critical']} critical")
if by_sev["warning"]:
parts.append(f"{by_sev['warning']} warnings")
if not parts:
parts.append("no issues")
return f"{len(flags)} flag(s): " + ", ".join(parts) + "."