File size: 4,217 Bytes
06c77d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
"""Smart QS Copilot - Streamlit app.
Upload a BOQ (CSV/Excel/PDF text) -> parse -> estimate -> anomalies -> plain-language review.
Deploy target: Hugging Face Spaces (free, no server)."""
import io
import json
import os
import sys

import pandas as pd
import streamlit as st

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from src.parser import enrich, parse_csv, parse_pdf, parse_pdf_text
from src.estimator import estimate
from src.anomalies import detect, summary as flag_summary
from src.llm import llm_review, fallback_review

st.set_page_config(page_title="Smart QS Copilot", page_icon="๐Ÿ—๏ธ", layout="wide")

st.title("๐Ÿ—๏ธ Smart QS Copilot")
st.caption(
    "AI screening for Bills of Quantities: parse, estimate, and flag anomalies "
    "against HK construction reference rates. Built for the Smart QS Hackathon 2026. "
    "Reference-based screening, not pricing advice."
)

uploaded = st.file_uploader("Upload a BOQ (CSV / Excel / PDF text)", type=["csv", "xlsx", "xls", "txt", "pdf"])
use_sample = st.button("Try the sample BOQ", type="primary")

rows = None
if use_sample:
    sample = os.path.join(os.path.dirname(os.path.abspath(__file__)), "samples", "sample_boq.csv")
    with open(sample, encoding="utf-8") as f:
        rows = parse_csv(f.read())
    st.info("Loaded sample BOQ (contains deliberately planted anomalies so you can see the flags).")
elif uploaded is not None:
    if uploaded.name.lower().endswith(".pdf"):
        rows = parse_pdf(uploaded.getvalue())
    elif uploaded.name.lower().endswith((".csv", ".txt")):
        raw = uploaded.getvalue().decode("utf-8", errors="ignore")
        rows = parse_csv(raw) if uploaded.name.lower().endswith(".csv") else parse_pdf_text(raw)
    else:
        try:
            df = pd.read_excel(uploaded)
            rows = parse_csv(df.to_csv(index=False))
        except Exception as e:
            st.error(f"Could not read {uploaded.name}: {e}")
    if not rows:
        st.error("No items parsed. Check that the file contains a recognizable BOQ table.")

if rows:
    rows = enrich(rows)
    flags = detect(rows)
    est = estimate(rows)

    c1, c2, c3 = st.columns(3)
    c1.metric("Items parsed", len(rows))
    c2.metric("Estimated total", f"HK${est['grand_total']:,.0f}", help=est["confidence"])
    c3.metric("Flags", flag_summary(flags))

    st.subheader("๐Ÿ“‹ Items")
    df = pd.DataFrame(rows)
    st.dataframe(
        df[["section", "item", "description", "unit", "qty", "rate", "ref_rate"]],
        use_container_width=True, hide_index=True,
    )

    st.subheader("๐Ÿšจ Anomaly flags")
    if flags:
        for f in flags:
            icon = {"critical": "๐Ÿ”ด", "warning": "๐ŸŸ ", "info": "๐Ÿ”ต"}[f["severity"]]
            sev = f["severity"].upper()
            st.markdown(f"**{icon} [{sev}] {f['description']}**  \n{f['detail']}")
    else:
        st.success("No anomalies detected.")

    st.subheader("๐Ÿงฎ Estimate by trade")
    trades_df = pd.DataFrame(
        [{"Trade": k, "Amount": v["amount"], "Items": v["count"]} for k, v in est["trades"].items()]
    ).sort_values("Amount", ascending=False)
    st.dataframe(trades_df, use_container_width=True, hide_index=True)
    st.caption(
        f"Items total HK${est['items_total']:,.0f} + preliminaries {est['preliminaries']/est['items_total']*100:.0f}% "
        f"HK${est['preliminaries']:,.0f} + contingency {est['contingency']/est['items_total']*100:.0f}% "
        f"HK${est['contingency']:,.0f} = **HK${est['grand_total']:,.0f}**"
    )

    st.subheader("๐Ÿง  Plain-language review")
    review, status = llm_review(len(rows), est["trades"], flags, est["grand_total"])
    if status != "llm_ok":
        review = fallback_review(flags, est["grand_total"])
        st.caption("(rule-based fallback; LLM review unavailable)")
    st.markdown(review)

    st.subheader("๐Ÿ›๏ธ Market context")
    try:
        ctx = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "hk_tenders.json"), encoding="utf-8"))
        for t in ctx["tenders"]:
            st.markdown(f"- **{t['ref']}** โ€” {t['title']} ({t['authority']})")
        st.caption(ctx.get("market_notes", ""))
    except Exception:
        pass