MEGA-BOQ-WEBAPP/boq_engine/profiles.py
MEGA INFRA f965e0cc93 Make the consultant profile actually apply, and show the build
The profile store was write-only: the app saved each consultant's detected
column map and displayed it, but every run re-detected from scratch. So the
original promise - drop their file in and it knows the format - wasn't kept,
and worse, the sidebar could show a stale map that didn't match the file in
front of you.

Now the saved map is applied, with two guards:
- it is only used when the columns it names still exist and still make sense
  for THIS file, so a template that changed shape falls back to detection
- 'Re-detect from scratch' in the sidebar overrides it deliberately

The hint path runs the same data verification and description-run derivation as
the fresh path. Without that a warm run placed 194 lines and a cold one placed
192 - both defensible, but a converter that gives two answers to the same pair
of files cannot be trusted on a tender. Cold and warm are now identical on both
sample pairs.

Also: the build SHA is baked in at image build time and shown in the sidebar,
because 'is my deploy current?' is otherwise unanswerable from outside -
Streamlit serves a client-rendered shell, so curl cannot see which commit is
serving.
2026-10-02 20:54:02 +02:00

157 lines
4.6 KiB
Python

"""
Per-consultant template profiles.
This is NOT machine learning and it is not "training". When a consultant's
workbook is first seen, we detect its layout - which row holds the headers,
which column is ITEM / UNIT / QTY / RATE / AMOUNT - and save that map under
the consultant's name. Next time their file arrives we skip detection and
apply the stored map.
Why that matters: it is deterministic and inspectable. Open a profile and you
can see exactly what the app decided, and correct it. A learned/fuzzy model
would give the same convenience with none of the auditability, and on a
tender document "the model decided" is not an acceptable answer to "where did
this number come from".
"""
from __future__ import annotations
import hashlib
import json
import os
import time
from dataclasses import asdict, dataclass, field
STORE_DIR = os.environ.get(
"BOQ_PROFILES",
os.path.join(os.path.expanduser("~"), ".boq_profiles"),
)
STORE_PATH = os.path.join(STORE_DIR, "profiles.json")
def file_fingerprint(path: str) -> str:
"""Content hash, so we can tell whether a file matches a known layout."""
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(65536), b""):
h.update(chunk)
return h.hexdigest()[:16]
@dataclass
class SheetProfile:
sheet: str
header_row: int
cols: dict[str, int]
data_rows: int
total_rows: int
desc_cols: list = field(default_factory=list)
consultant: str = ""
@dataclass
class Profile:
consultant: str
sheets: list[SheetProfile] = field(default_factory=list)
fingerprint: str = ""
saved: str = ""
uses: int = 0
notes: list[str] = field(default_factory=list)
def summary(self) -> str:
parts = []
for s in self.sheets:
cols = ", ".join(f"{k}={v}" for k, v in sorted(
s.cols.items(), key=lambda kv: kv[1]))
parts.append(f"{s.sheet} (hdr row {s.header_row}): {cols}")
return "\n".join(parts)
def _load_all() -> dict[str, dict]:
if not os.path.exists(STORE_PATH):
return {}
try:
with open(STORE_PATH, "r", encoding="utf-8") as fh:
return json.load(fh)
except (json.JSONDecodeError, OSError):
return {}
def _save_all(data: dict) -> None:
os.makedirs(STORE_DIR, exist_ok=True)
tmp = STORE_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=1, sort_keys=True)
os.replace(tmp, STORE_PATH)
def list_profiles() -> list[Profile]:
out = []
for name, raw in sorted(_load_all().items()):
try:
p = Profile(
consultant=raw.get("consultant", name),
sheets=[SheetProfile(**s) for s in raw.get("sheets", [])],
fingerprint=raw.get("fingerprint", ""),
saved=raw.get("saved", ""),
uses=raw.get("uses", 0),
notes=raw.get("notes", []),
)
except TypeError:
continue
out.append(p)
return out
def get(consultant: str) -> Profile | None:
raw = _load_all().get(consultant.lower().strip())
if not raw:
return None
return Profile(
consultant=raw.get("consultant", consultant),
sheets=[SheetProfile(**s) for s in raw.get("sheets", [])],
fingerprint=raw.get("fingerprint", ""),
saved=raw.get("saved", ""),
uses=raw.get("uses", 0),
notes=raw.get("notes", []),
)
def save(consultant: str, layouts: dict, fingerprint: str,
notes: list[str] | None = None) -> Profile:
consultant = consultant.strip()
if not consultant:
raise ValueError("consultant name is required")
prof = Profile(
consultant=consultant,
sheets=[SheetProfile(
sheet=name,
header_row=lay.header_row,
cols=dict(lay.cols),
data_rows=len(lay.data_rows),
total_rows=len(lay.total_rows),
) for name, lay in layouts.items()],
fingerprint=fingerprint,
saved=time.strftime("%Y-%m-%d %H:%M"),
notes=notes or [],
)
data = _load_all()
prev = data.get(consultant.lower(), {})
prof.uses = int(prev.get("uses", 0))
data[consultant.lower()] = asdict(prof)
_save_all(data)
return prof
def touch(consultant: str) -> None:
data = _load_all()
k = consultant.lower().strip()
if k in data:
data[k]["uses"] = int(data[k].get("uses", 0)) + 1
_save_all(data)
def delete(consultant: str) -> None:
data = _load_all()
if data.pop(consultant.lower().strip(), None) is not None:
_save_all(data)