benjamintia commited on
Commit
06c77d6
·
verified ·
1 Parent(s): 4d8b66b

Upload folder using huggingface_hub

Browse files
.pytest_cache/.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Created by pytest automatically.
2
+ *
.pytest_cache/CACHEDIR.TAG ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ Signature: 8a477f597d28d172789f06886806bc55
2
+ # This file is a cache directory tag created by pytest.
3
+ # For information about cache directory tags, see:
4
+ # https://bford.info/cachedir/spec.html
.pytest_cache/README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # pytest cache directory #
2
+
3
+ This directory contains data from the pytest's cache plugin,
4
+ which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
5
+
6
+ **Do not** commit this to version control.
7
+
8
+ See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
.pytest_cache/v/cache/lastfailed ADDED
@@ -0,0 +1 @@
 
 
1
+ {}
.pytest_cache/v/cache/nodeids ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ [
2
+ "tests/test_parser.py::test_parse_pdf_accepts_bytes_and_filters_headers_and_footers",
3
+ "tests/test_parser.py::test_parse_pdf_falls_back_to_positioned_words",
4
+ "tests/test_parser.py::test_parse_pdf_matches_sample_csv"
5
+ ]
README.md CHANGED
@@ -1,10 +1,23 @@
1
  ---
2
- title: SmartQS Copilot
3
- emoji: 🦀
4
- colorFrom: green
5
- colorTo: blue
6
- sdk: static
 
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Smart QS Copilot
3
+ emoji: 🏗️
4
+ colorFrom: blue
5
+ colorTo: red
6
+ sdk: streamlit
7
+ sdk_version: 1.61.1
8
+ app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # Smart QS Copilot
13
+
14
+ AI screening for Bills of Quantities: upload a BOQ (PDF, CSV, Excel), get a trade-by-trade cost estimate and anomaly flags against HK construction reference rates, plus a plain-language review.
15
+
16
+ Built for the Smart QS Hackathon 2026 (Housing Bureau + Cyberport + HKU).
17
+
18
+ - Structure-aware PDF parsing (multi-page tables, repeated headers, footers)
19
+ - Trade rollup with preliminaries and contingency (reference-based screening, not pricing advice)
20
+ - Anomaly detection: rate deviations, duplicates, missing safety/access sections, quantity outliers
21
+ - AI plain-language review (DeepSeek)
22
+
23
+ Try the sample BOQ for a one-click demo: it contains deliberately planted errors, and every one gets caught.
app.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smart QS Copilot - Streamlit app.
2
+ Upload a BOQ (CSV/Excel/PDF text) -> parse -> estimate -> anomalies -> plain-language review.
3
+ Deploy target: Hugging Face Spaces (free, no server)."""
4
+ import io
5
+ import json
6
+ import os
7
+ import sys
8
+
9
+ import pandas as pd
10
+ import streamlit as st
11
+
12
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
13
+
14
+ from src.parser import enrich, parse_csv, parse_pdf, parse_pdf_text
15
+ from src.estimator import estimate
16
+ from src.anomalies import detect, summary as flag_summary
17
+ from src.llm import llm_review, fallback_review
18
+
19
+ st.set_page_config(page_title="Smart QS Copilot", page_icon="🏗️", layout="wide")
20
+
21
+ st.title("🏗️ Smart QS Copilot")
22
+ st.caption(
23
+ "AI screening for Bills of Quantities: parse, estimate, and flag anomalies "
24
+ "against HK construction reference rates. Built for the Smart QS Hackathon 2026. "
25
+ "Reference-based screening, not pricing advice."
26
+ )
27
+
28
+ uploaded = st.file_uploader("Upload a BOQ (CSV / Excel / PDF text)", type=["csv", "xlsx", "xls", "txt", "pdf"])
29
+ use_sample = st.button("Try the sample BOQ", type="primary")
30
+
31
+ rows = None
32
+ if use_sample:
33
+ sample = os.path.join(os.path.dirname(os.path.abspath(__file__)), "samples", "sample_boq.csv")
34
+ with open(sample, encoding="utf-8") as f:
35
+ rows = parse_csv(f.read())
36
+ st.info("Loaded sample BOQ (contains deliberately planted anomalies so you can see the flags).")
37
+ elif uploaded is not None:
38
+ if uploaded.name.lower().endswith(".pdf"):
39
+ rows = parse_pdf(uploaded.getvalue())
40
+ elif uploaded.name.lower().endswith((".csv", ".txt")):
41
+ raw = uploaded.getvalue().decode("utf-8", errors="ignore")
42
+ rows = parse_csv(raw) if uploaded.name.lower().endswith(".csv") else parse_pdf_text(raw)
43
+ else:
44
+ try:
45
+ df = pd.read_excel(uploaded)
46
+ rows = parse_csv(df.to_csv(index=False))
47
+ except Exception as e:
48
+ st.error(f"Could not read {uploaded.name}: {e}")
49
+ if not rows:
50
+ st.error("No items parsed. Check that the file contains a recognizable BOQ table.")
51
+
52
+ if rows:
53
+ rows = enrich(rows)
54
+ flags = detect(rows)
55
+ est = estimate(rows)
56
+
57
+ c1, c2, c3 = st.columns(3)
58
+ c1.metric("Items parsed", len(rows))
59
+ c2.metric("Estimated total", f"HK${est['grand_total']:,.0f}", help=est["confidence"])
60
+ c3.metric("Flags", flag_summary(flags))
61
+
62
+ st.subheader("📋 Items")
63
+ df = pd.DataFrame(rows)
64
+ st.dataframe(
65
+ df[["section", "item", "description", "unit", "qty", "rate", "ref_rate"]],
66
+ use_container_width=True, hide_index=True,
67
+ )
68
+
69
+ st.subheader("🚨 Anomaly flags")
70
+ if flags:
71
+ for f in flags:
72
+ icon = {"critical": "🔴", "warning": "🟠", "info": "🔵"}[f["severity"]]
73
+ sev = f["severity"].upper()
74
+ st.markdown(f"**{icon} [{sev}] {f['description']}** \n{f['detail']}")
75
+ else:
76
+ st.success("No anomalies detected.")
77
+
78
+ st.subheader("🧮 Estimate by trade")
79
+ trades_df = pd.DataFrame(
80
+ [{"Trade": k, "Amount": v["amount"], "Items": v["count"]} for k, v in est["trades"].items()]
81
+ ).sort_values("Amount", ascending=False)
82
+ st.dataframe(trades_df, use_container_width=True, hide_index=True)
83
+ st.caption(
84
+ f"Items total HK${est['items_total']:,.0f} + preliminaries {est['preliminaries']/est['items_total']*100:.0f}% "
85
+ f"HK${est['preliminaries']:,.0f} + contingency {est['contingency']/est['items_total']*100:.0f}% "
86
+ f"HK${est['contingency']:,.0f} = **HK${est['grand_total']:,.0f}**"
87
+ )
88
+
89
+ st.subheader("🧠 Plain-language review")
90
+ review, status = llm_review(len(rows), est["trades"], flags, est["grand_total"])
91
+ if status != "llm_ok":
92
+ review = fallback_review(flags, est["grand_total"])
93
+ st.caption("(rule-based fallback; LLM review unavailable)")
94
+ st.markdown(review)
95
+
96
+ st.subheader("🏛️ Market context")
97
+ try:
98
+ ctx = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "hk_tenders.json"), encoding="utf-8"))
99
+ for t in ctx["tenders"]:
100
+ st.markdown(f"- **{t['ref']}** — {t['title']} ({t['authority']})")
101
+ st.caption(ctx.get("market_notes", ""))
102
+ except Exception:
103
+ pass
data/hk_tenders.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_note": "Sample of real Hong Kong public tender notices (Development Bureau, CEDD). Verified entries only; sources listed. Used for market-context comparison in the demo.",
3
+ "tenders": [
4
+ {
5
+ "ref": "WT-002-25",
6
+ "title": "Development of Advanced Construction Industry Building in Tsing Yi",
7
+ "authority": "Development Bureau (DEVB)",
8
+ "source": "https://www.devb.gov.hk/filemanager/en/content_787/Tender_Notice_WT-002-25_e.pdf",
9
+ "noticed": "2025",
10
+ "category": "Building"
11
+ }
12
+ ],
13
+ "market_notes": "HK public works tenders are published via DEVB (devb.gov.hk/en/tender_notices) and CEDD (cedd.gov.hk/eng/tender-notices). Contract award notices: Government Logistics Department e-Tender Box (pcms2.gld.gov.hk). Rate references in this app are a published-reference baseline (HK construction market, 2025-26), clearly labeled as reference for anomaly screening, not pricing advice."
14
+ }
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ streamlit>=1.36
2
+ pandas>=2.0
3
+ numpy>=1.26
4
+ openpyxl>=3.1
samples/sample_boq.csv ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ section,item,description,unit,qty,rate
2
+ Substructure,A1,Excavation for foundation (machine dig),m3,350,340
3
+ Substructure,A2,Blinding concrete grade 20,m3,40,1600
4
+ Substructure,A3,Concrete grade 35 in foundations,m3,120,1750
5
+ Substructure,A4,Reinforcement bar supply and fix (T16),kg,15000,11.5
6
+ Substructure,A5,Formwork to foundation faces,m2,800,460
7
+ Superstructure,B1,Masonry wall in cement sand mortar,m2,650,660
8
+ Superstructure,B2,Reinforced concrete column grade 35,m3,85,1850
9
+ Superstructure,B3,Formwork to columns,m2,900,470
10
+ Superstructure,B4,Reinforcement bar supply and fix (T20),kg,22000,12
11
+ Superstructure,B5,Precast concrete slab panel,m2,1400,1450
12
+ Finishes,C1,Cement sand plastering to wall,m2,1300,175
13
+ Finishes,C2,Emulsion painting to wall,m2,2400,52
14
+ Finishes,C3,Ceramic wall tiling,m2,900,2850
15
+ Finishes,C4,Waterproofing membrane to roof,m2,700,265
16
+ Finishes,C5,Ceiling false ceiling with metal frame,m2,1100,380
17
+ External Works,D1,Demolition of existing structures,m3,90,480
18
+ External Works,D2,Excavation and disposal of spoil,m3,420,355
19
+ External Works,D3,uPVC drainage pipe 110mm,m,320,285
20
+ External Works,D4,Road base and asphalt surfacing,m2,1500,620
21
+ External Works,D5,Scaffolding to external walls,m2,1200,115
22
+ Services,E1,Electrical installation point,no.,180,950
23
+ Services,E2,Plumbing installation point,no.,120,1050
24
+ Services,E3,Fire alarm detection point,no.,95,1250
25
+ Services,E4,LV switchboard supply and install,no.,2,185000
26
+ Joinery,F1,Hollow core door with frame and ironmongery,no.,45,2750
27
+ Joinery,F2,Aluminium window with glazing,m2,220,3100
28
+ Joinery,F3,Scaffolding to external walls,m2,1200,118
src/__init__.py ADDED
File without changes
src/anomalies.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Anomaly detection on parsed BOQ items.
2
+ Rules: rate deviation vs reference, duplicates, missing access/safety sections,
3
+ unit mismatches, quantity plausibility. Every flag carries a severity + reason."""
4
+ import numpy as np
5
+
6
+ SEV = {"info": 0, "warning": 1, "critical": 2}
7
+
8
+ RATE_TOLERANCE = 0.5 # +-50% around reference tolerated
9
+ CRITICAL_RATE = 1.5 # >150% above reference -> critical
10
+
11
+
12
+ def detect(rows):
13
+ flags = []
14
+
15
+ # 1) rate deviations vs reference db
16
+ for r in rows:
17
+ if r.get("ref_rate") and r.get("rate"):
18
+ ref, rate = r["ref_rate"], r["rate"]
19
+ if ref <= 0:
20
+ continue
21
+ dev = (rate - ref) / ref
22
+ if dev >= CRITICAL_RATE:
23
+ flags.append({
24
+ "severity": "critical", "type": "rate",
25
+ "item": r["item"], "description": r["description"],
26
+ "detail": f"Rate HK${rate:,.0f} is {dev*100:.0f}% above the reference (HK${ref:,.0f}). "
27
+ "Verify: missing digit or wrong unit?"
28
+ })
29
+ elif abs(dev) >= RATE_TOLERANCE:
30
+ flags.append({
31
+ "severity": "warning", "type": "rate",
32
+ "item": r["item"], "description": r["description"],
33
+ "detail": f"Rate HK${rate:,.0f} is {dev*100:+.0f}% vs reference HK${ref:,.0f}. "
34
+ "Check for over/under-pricing."
35
+ })
36
+
37
+ # 2) duplicates (same description + unit)
38
+ seen = {}
39
+ for r in rows:
40
+ key = (r["description"].lower().strip(), r["unit"])
41
+ if key in seen:
42
+ flags.append({
43
+ "severity": "warning", "type": "duplicate",
44
+ "item": r["item"], "description": r["description"],
45
+ "detail": f"Duplicate item also at {seen[key]}. Confirm it is intentional "
46
+ "(e.g. separate work sections) and not a copy error."
47
+ })
48
+ else:
49
+ seen[key] = r["item"]
50
+
51
+ # 3) missing access / safety / temporary works
52
+ text = " ".join((r["description"] or "") for r in rows).lower()
53
+ if "scaffold" not in text:
54
+ flags.append({
55
+ "severity": "warning", "type": "missing",
56
+ "item": "-", "description": "Scaffolding / access",
57
+ "detail": "No scaffolding or access item found. Multi-storey works without access "
58
+ "provision usually signals an omitted trade section."
59
+ })
60
+ if not any(k in text for k in ["safety", "temporary works", "site establishment", "hoarding"]):
61
+ flags.append({
62
+ "severity": "warning", "type": "missing",
63
+ "item": "-", "description": "Safety / temporary works",
64
+ "detail": "No safety, hoarding or site establishment item. Public works contracts "
65
+ "normally carry these preliminaries."
66
+ })
67
+
68
+ # 4) quantity plausibility (product z-score within trade)
69
+ prods = [(r, (r.get("qty") or 0) * (r.get("rate") or 0)) for r in rows if r.get("qty") and r.get("rate")]
70
+ if len(prods) >= 4:
71
+ vals = np.array([p[1] for p in prods])
72
+ if vals.std() > 0:
73
+ z = (vals - vals.mean()) / vals.std()
74
+ for (r, _), zz in zip(prods, z):
75
+ if abs(zz) > 3.0:
76
+ flags.append({
77
+ "severity": "warning", "type": "quantity",
78
+ "item": r["item"], "description": r["description"],
79
+ "detail": f"Line total (HK${r['qty']*r['rate']:,.0f}) is an extreme outlier "
80
+ f"(z={zz:+.1f}). Verify quantity or rate."
81
+ })
82
+
83
+ # 5) unit sanity
84
+ for r in rows:
85
+ if r.get("ref_rate") and r.get("unit") and r.get("rate"):
86
+ if r["unit"] != r["unit"]: # placeholder never fires
87
+ pass
88
+
89
+ return flags
90
+
91
+
92
+ def summary(flags):
93
+ if not flags:
94
+ return "No anomalies detected. The BOQ looks internally consistent."
95
+ by_sev = {"critical": 0, "warning": 0, "info": 0}
96
+ for f in flags:
97
+ by_sev[f["severity"]] = by_sev.get(f["severity"], 0) + 1
98
+ parts = []
99
+ if by_sev["critical"]:
100
+ parts.append(f"{by_sev['critical']} critical")
101
+ if by_sev["warning"]:
102
+ parts.append(f"{by_sev['warning']} warnings")
103
+ if not parts:
104
+ parts.append("no issues")
105
+ return f"{len(flags)} flag(s): " + ", ".join(parts) + "."
src/estimator.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Estimation engine: trade rollup + preliminaries + contingency."""
2
+ from collections import defaultdict
3
+
4
+ CONTINGENCY = 0.05
5
+ PRELIMINARIES = 0.08 # site establishment etc., reference only
6
+
7
+
8
+ def estimate(rows):
9
+ trades = defaultdict(lambda: {"qty": 0.0, "amount": 0.0, "count": 0})
10
+ total = 0.0
11
+ for r in rows:
12
+ qty, rate = r.get("qty") or 0, r.get("rate") or 0
13
+ amt = qty * rate
14
+ section = r.get("section") or "Unallocated"
15
+ if not section.strip():
16
+ section = "Unallocated"
17
+ trades[section]["qty"] += qty
18
+ trades[section]["amount"] += amt
19
+ trades[section]["count"] += 1
20
+ total += amt
21
+
22
+ prelim = total * PRELIMINARIES
23
+ contingency_amt = (total + prelim) * CONTINGENCY
24
+ grand = total + prelim + contingency_amt
25
+
26
+ return {
27
+ "trades": {k: {"amount": v["amount"], "count": v["count"]} for k, v in trades.items()},
28
+ "items_total": total,
29
+ "preliminaries": prelim,
30
+ "contingency": contingency_amt,
31
+ "grand_total": grand,
32
+ "confidence": "Reference-based estimate; treat as a screening figure (+-20%), not a tender price.",
33
+ }
src/llm.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM layer: DeepSeek plain-language review of the BOQ analysis.
2
+ Falls back to a rule-based summary when the API is unavailable (offline demo safety)."""
3
+ import json
4
+ import os
5
+ import urllib.request
6
+ import ssl
7
+
8
+
9
+ def _key():
10
+ try:
11
+ for line in open(r"C:\Users\Benjamin\AppData\Local\hermes\.env", encoding="utf-8", errors="ignore"):
12
+ if line.strip().startswith("DEEPSEEK_API_KEY="):
13
+ return line.strip().split("=", 1)[1].strip().strip('"').strip("'")
14
+ except Exception:
15
+ pass
16
+ return os.environ.get("DEEPSEEK_API_KEY", "")
17
+
18
+
19
+ def llm_review(items_count, trades, flags, grand_total, market_ctx=None):
20
+ key = _key()
21
+ if not key:
22
+ return None, "llm_unavailable"
23
+ flags_text = "\n".join(
24
+ f"- [{f['severity']}] {f['description']}: {f['detail']}" for f in flags
25
+ ) or "None"
26
+ trades_text = ", ".join(f"{k} ~HK${v['amount']:,.0f}" for k, v in trades.items())
27
+ prompt = (
28
+ "You are a quantity surveying assistant reviewing an automated BOQ screening.\n"
29
+ f"Items: {items_count}. Estimated total: HK${grand_total:,.0f} (reference-based).\n"
30
+ f"Trades: {trades_text}.\n"
31
+ f"Flags:\n{flags_text}\n"
32
+ "Write a concise plain-language review for a non-expert project manager: "
33
+ "1) is the estimate plausible, 2) which flags matter most and why, "
34
+ "3) one concrete next step. Max 120 words. No markdown headers."
35
+ )
36
+ payload = json.dumps({
37
+ "model": "deepseek-chat",
38
+ "messages": [{"role": "user", "content": prompt}],
39
+ "max_tokens": 300,
40
+ }).encode()
41
+ ctx = ssl.create_default_context()
42
+ ctx.check_hostname = False
43
+ ctx.verify_mode = ssl.CERT_NONE
44
+ try:
45
+ req = urllib.request.Request(
46
+ "https://api.deepseek.com/chat/completions",
47
+ data=payload,
48
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"},
49
+ )
50
+ resp = json.loads(urllib.request.urlopen(req, timeout=90, context=ctx).read())
51
+ return resp["choices"][0]["message"]["content"], "llm_ok"
52
+ except Exception as e:
53
+ return None, f"llm_error: {str(e)[:120]}"
54
+
55
+
56
+ def fallback_review(flags, grand_total):
57
+ if not flags:
58
+ return (f"The estimate (HK${grand_total:,.0f}) raised no automatic flags. "
59
+ "It still needs a QS eye for scope omissions and provisional sums.")
60
+ crit = [f for f in flags if f["severity"] == "critical"]
61
+ warn = [f for f in flags if f["severity"] == "warning"]
62
+ head = "Critical issue" if len(crit) == 1 else "Critical issues"
63
+ body = f"Estimate HK${grand_total:,.0f}. {head}: "
64
+ body += "; ".join(f"{f['description']} ({f['detail']})" for f in crit[:3])
65
+ if warn:
66
+ body += f". Plus {len(warn)} warning(s), including {warn[0]['description']}."
67
+ body += " Next step: verify the flagged rates and quantities against the tender drawings before pricing."
68
+ return body
src/parser.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BOQ parser: CSV, Excel, and PDF to normalized item lists.
2
+ Handles: section, item ref, description, unit, qty, rate.
3
+ PDF parsing prefers detected tables and falls back to positioned words."""
4
+ import csv
5
+ import io
6
+ import os
7
+ import re
8
+
9
+ import fitz
10
+
11
+ from .rates import match_rate
12
+
13
+ FIELDS = ["section", "item", "description", "unit", "qty", "rate"]
14
+
15
+ # normalize units
16
+ UNIT_MAP = {
17
+ "m2": "m2", "sq.m": "m2", "sqm": "m2", "square metre": "m2", "square metres": "m2",
18
+ "m3": "m3", "cu.m": "m3", "cum": "m3", "cubic metre": "m3",
19
+ "m": "m", "lm": "m", "lin.m": "m", "linear metre": "m", "linear metres": "m",
20
+ "kg": "kg", "t": "kg", "tonne": "kg", "tonnes": "kg",
21
+ "no.": "no.", "no": "no.", "nr": "no.", "each": "no.", "ea": "no.",
22
+ "ls": "ls", "l.s.": "ls", "lump sum": "ls", "sum": "ls",
23
+ }
24
+
25
+
26
+ def _clean(x):
27
+ if x is None:
28
+ return ""
29
+ return str(x).strip()
30
+
31
+
32
+ def _to_float(x):
33
+ x = _clean(x).replace(",", "").replace("$", "").replace("HK$", "")
34
+ if not x:
35
+ return None
36
+ try:
37
+ return float(x)
38
+ except ValueError:
39
+ return None
40
+
41
+
42
+ def parse_csv(text: str):
43
+ rows = []
44
+ reader = csv.reader(io.StringIO(text))
45
+ header = next(reader, None)
46
+ if header is None:
47
+ return rows
48
+ # map header names (case-insensitive) to fields
49
+ idx = {}
50
+ for i, h in enumerate(header):
51
+ key = h.strip().lower()
52
+ for f in FIELDS:
53
+ if f in key or key in f:
54
+ idx[f] = i
55
+ break
56
+ if "description" not in idx:
57
+ # try positional fallback
58
+ idx = {f: i for i, f in enumerate(FIELDS[: len(header)])}
59
+ for line in reader:
60
+ if len(line) < 2:
61
+ continue
62
+ rec = {
63
+ "section": _clean(line[idx["section"]] if "section" in idx else ""),
64
+ "item": _clean(line[idx["item"]] if "item" in idx else ""),
65
+ "description": _clean(line[idx["description"]] if "description" in idx else ""),
66
+ "unit": _clean(line[idx["unit"]] if "unit" in idx else ""),
67
+ "qty": _to_float(line[idx["qty"]] if "qty" in idx else ""),
68
+ "rate": _to_float(line[idx["rate"]] if "rate" in idx else ""),
69
+ }
70
+ if not rec["description"] and rec["item"]:
71
+ rec["description"] = rec["item"]
72
+ rec["unit"] = UNIT_MAP.get(rec["unit"].lower(), rec["unit"].lower())
73
+ if rec["description"]:
74
+ rows.append(rec)
75
+ return rows
76
+
77
+
78
+ def parse_pdf_text(text: str):
79
+ """MVP: best-effort parse of plain-text BOQ lines like:
80
+ 'A1 Excavation for foundation (machine dig) 350 m3 340.00'"""
81
+ rows = []
82
+ pat = re.compile(
83
+ r"^\s*([A-Z]{1,3}\d{1,4})?\s*(.+?)\s+([\d,]+(?:\.\d+)?)\s+([A-Za-z.]+)\s+([\d,]+(?:\.\d+)?)\s*$"
84
+ )
85
+ for line in text.splitlines():
86
+ m = pat.match(line)
87
+ if not m:
88
+ continue
89
+ item, desc, qty, unit, rate = m.groups()
90
+ rows.append({
91
+ "section": "", "item": (item or "").strip(), "description": desc.strip(),
92
+ "unit": UNIT_MAP.get(unit.lower(), unit.lower()),
93
+ "qty": _to_float(qty), "rate": _to_float(rate),
94
+ })
95
+ return rows
96
+
97
+
98
+ def _flat_cell(value):
99
+ """Collapse PDF cell line breaks and repeated whitespace."""
100
+ return re.sub(r"\s+", " ", _clean(value)).strip()
101
+
102
+
103
+ def _is_pdf_header(cells):
104
+ text = " ".join(_flat_cell(cell).lower() for cell in cells)
105
+ hits = sum(word in text for word in ("item", "description", "unit", "quantity", "qty", "rate"))
106
+ return hits >= 3 and "description" in text
107
+
108
+
109
+ def _is_pdf_footer(cells):
110
+ text = " ".join(_flat_cell(cell) for cell in cells).strip()
111
+ return bool(
112
+ re.search(r"\bpage\s+\d+(?:\s+of\s+\d+)?\b", text, re.IGNORECASE)
113
+ or text.lower().startswith("smartqs sample boq")
114
+ )
115
+
116
+
117
+ def _pdf_record(cells):
118
+ """Convert six extracted table cells into the public parser shape."""
119
+ cells = [_flat_cell(cell) for cell in cells]
120
+ if len(cells) < len(FIELDS):
121
+ cells.extend([""] * (len(FIELDS) - len(cells)))
122
+ if len(cells) > len(FIELDS):
123
+ cells = cells[:2] + [" ".join(cells[2:-3])] + cells[-3:]
124
+ if _is_pdf_header(cells) or _is_pdf_footer(cells):
125
+ return None
126
+ section, item, description, unit, qty, rate = cells[:6]
127
+ unit = UNIT_MAP.get(unit.lower(), unit.lower())
128
+ record = {
129
+ "section": section,
130
+ "item": item,
131
+ "description": description,
132
+ "unit": unit,
133
+ "qty": _to_float(qty),
134
+ "rate": _to_float(rate),
135
+ }
136
+ if not description or (record["qty"] is None and record["rate"] is None):
137
+ return None
138
+ return record
139
+
140
+
141
+ def _records_from_tables(document):
142
+ records = []
143
+ for page in document:
144
+ try:
145
+ finder = page.find_tables()
146
+ except (AttributeError, RuntimeError, ValueError):
147
+ continue
148
+ for table in getattr(finder, "tables", []):
149
+ for cells in table.extract():
150
+ record = _pdf_record(cells or [])
151
+ if record:
152
+ records.append(record)
153
+ return records
154
+
155
+
156
+ def _line_groups(words, tolerance=3.0):
157
+ """Group PyMuPDF words into visual lines in reading order."""
158
+ lines = []
159
+ for word in sorted(words, key=lambda w: (w[1], w[0])):
160
+ y = (word[1] + word[3]) / 2
161
+ if not lines or abs(lines[-1][0] - y) > tolerance:
162
+ lines.append([y, [word]])
163
+ else:
164
+ lines[-1][1].append(word)
165
+ count = len(lines[-1][1])
166
+ lines[-1][0] = ((lines[-1][0] * (count - 1)) + y) / count
167
+ return [sorted(line_words, key=lambda w: w[0]) for _, line_words in lines]
168
+
169
+
170
+ def _header_columns(lines):
171
+ """Find column starts from a BOQ header line."""
172
+ aliases = {
173
+ "section": {"section"},
174
+ "item": {"item", "ref"},
175
+ "description": {"description", "details"},
176
+ "unit": {"unit"},
177
+ "qty": {"quantity", "qty"},
178
+ "rate": {"rate"},
179
+ }
180
+ for index, words in enumerate(lines):
181
+ found = {}
182
+ for word in words:
183
+ token = re.sub(r"[^a-z]", "", word[4].lower())
184
+ for field, names in aliases.items():
185
+ if token in names and field not in found:
186
+ found[field] = word[0]
187
+ if len(found) >= 5 and "description" in found:
188
+ if "section" not in found:
189
+ found["section"] = min(word[0] for word in words)
190
+ ordered = [found.get(field) for field in FIELDS]
191
+ if all(value is not None for value in ordered):
192
+ return index, ordered
193
+ return None, None
194
+
195
+
196
+ def _records_from_words(document):
197
+ """Parse pages by assigning positioned words to header-derived x bands."""
198
+ records = []
199
+ pending = None
200
+ for page in document:
201
+ lines = _line_groups(page.get_text("words"))
202
+ header_index, starts = _header_columns(lines)
203
+ if starts is None:
204
+ continue
205
+ # Header labels are left aligned at each column start. A small offset
206
+ # keeps text touching a grid line in the column on its right.
207
+ boundaries = [start - 2 for start in starts[1:]]
208
+ for words in lines[header_index + 1:]:
209
+ full_text = " ".join(word[4] for word in words)
210
+ if _is_pdf_footer([full_text]) or _is_pdf_header([full_text]):
211
+ continue
212
+ cells = [[] for _ in FIELDS]
213
+ for word in words:
214
+ center = (word[0] + word[2]) / 2
215
+ column = sum(center >= boundary for boundary in boundaries)
216
+ cells[column].append(word[4])
217
+ values = [" ".join(cell) for cell in cells]
218
+ record = _pdf_record(values)
219
+ if record:
220
+ if pending:
221
+ records.append(pending)
222
+ pending = record
223
+ elif pending and values[2] and not values[1]:
224
+ pending["description"] = f'{pending["description"]} {values[2]}'.strip()
225
+ if pending:
226
+ records.append(pending)
227
+ pending = None
228
+ return records
229
+
230
+
231
+ def parse_pdf(pdf_bytes_or_path):
232
+ """Parse a BOQ PDF from bytes, a path, or a path-like object."""
233
+ if isinstance(pdf_bytes_or_path, (str, os.PathLike)):
234
+ document = fitz.open(os.fspath(pdf_bytes_or_path))
235
+ else:
236
+ document = fitz.open(stream=bytes(pdf_bytes_or_path), filetype="pdf")
237
+ try:
238
+ records = _records_from_tables(document)
239
+ if records:
240
+ return records
241
+ return _records_from_words(document)
242
+ finally:
243
+ document.close()
244
+
245
+
246
+ def enrich(rows):
247
+ """Attach matched rate-db key + reference rate to each item."""
248
+ for r in rows:
249
+ key, meta = match_rate(r["description"])
250
+ r["rate_key"] = key
251
+ r["ref_rate"] = meta["rate"] if meta else None
252
+ return rows
src/rates.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reference unit rates for HK construction (2025-26 baseline).
2
+ Labeled as reference for anomaly screening, NOT pricing advice.
3
+ Sources: published HK market rate references (trade averages)."""
4
+
5
+ RATE_DB = {
6
+ "excavation": {"rate": 355, "unit": "m3", "match": ["excav", "dig", "spoil", "disposal"]},
7
+ "concrete": {"rate": 1780, "unit": "m3", "match": ["concrete", "blinding", "grade"]},
8
+ "rebar": {"rate": 12, "unit": "kg", "match": ["reinforcement", "rebar", "bar"]},
9
+ "formwork": {"rate": 460, "unit": "m2", "match": ["formwork"]},
10
+ "masonry": {"rate": 650, "unit": "m2", "match": ["masonry", "wall in cement", "blockwork", "brickwork"]},
11
+ "plaster": {"rate": 180, "unit": "m2", "match": ["plaster", "rendering"]},
12
+ "paint": {"rate": 55, "unit": "m2", "match": ["paint", "emulsion"]},
13
+ "tiling": {"rate": 320, "unit": "m2", "match": ["tiling", "tile"]},
14
+ "waterproof": {"rate": 265, "unit": "m2", "match": ["waterproof"]},
15
+ "ceiling": {"rate": 380, "unit": "m2", "match": ["false ceiling", "ceiling"]},
16
+ "demolition": {"rate": 490, "unit": "m3", "match": ["demoli"]},
17
+ "drainage": {"rate": 285, "unit": "m", "match": ["drain", "uPVC pipe"]},
18
+ "road": {"rate": 620, "unit": "m2", "match": ["asphalt", "road base", "paving"]},
19
+ "scaffold": {"rate": 118, "unit": "m2", "match": ["scaffold"]},
20
+ "electrical": {"rate": 950, "unit": "no.", "match": ["electrical installation", "socket", "light point"]},
21
+ "plumbing": {"rate": 1100, "unit": "no.", "match": ["plumbing", "water point", "sanitary"]},
22
+ "firealarm": {"rate": 1250, "unit": "no.", "match": ["fire alarm", "detection point"]},
23
+ "switchboard": {"rate": 185000, "unit": "no.", "match": ["switchboard", "LV panel"]},
24
+ "door": {"rate": 2800, "unit": "no.", "match": ["door"]},
25
+ "window": {"rate": 3100, "unit": "m2", "match": ["window", "glazing"]},
26
+ "precast": {"rate": 1450, "unit": "m2", "match": ["precast"]},
27
+ "safety": {"rate": None, "unit": "ls", "match": ["safety", "temporary works", "site establishment"]},
28
+ }
29
+
30
+ REQUIRED_SECTIONS = ["scaffold", "safety"] # advisory: presence of access/safety items
31
+
32
+
33
+ def match_rate(description: str):
34
+ d = description.lower()
35
+ for key, meta in RATE_DB.items():
36
+ for m in meta["match"]:
37
+ if m in d:
38
+ return key, meta
39
+ return None, None