ankarb commited on
Commit
65268e7
·
verified ·
1 Parent(s): b8d9925

Upload 2 files

Browse files
hardware_qa_assistant_helpers.py ADDED
@@ -0,0 +1,847 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # hardware_qa_assistant_helpers.py
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os, io, re, glob, tempfile, urllib.parse, time
6
+ from typing import List, Dict, Any, Tuple, Optional
7
+
8
+ # ---- Vector DB (persistent, local) ----
9
+ import chromadb
10
+ from chromadb.utils import embedding_functions
11
+
12
+ from chromadb.utils import embedding_functions
13
+
14
+ CHROMA_DIR = os.getenv("CHROMA_DIR", ".chroma_hwqa_workspace")
15
+ os.makedirs(CHROMA_DIR, exist_ok=True)
16
+ chroma = chromadb.PersistentClient(path=CHROMA_DIR)
17
+
18
+ # --- Demo toggle (keep HF, just choose smaller model when DEMO_MODE is on) ---
19
+ _DEMO = os.getenv("DEMO_MODE", "0").lower() in ("1", "true", "yes")
20
+ _EMB_DEFAULT = "sentence-transformers/all-mpnet-base-v2"
21
+ _EMB_DEMO = "sentence-transformers/all-MiniLM-L6-v2" # small, fast, great for demos
22
+ EMB_MODEL = os.getenv("EMB_MODEL") or (_EMB_DEMO if _DEMO else _EMB_DEFAULT)
23
+
24
+ _embed_fn = None
25
+ def get_embed_fn():
26
+ """Create the embedding function on first use (saves memory + port binds fast)."""
27
+ global _embed_fn
28
+ if _embed_fn is None:
29
+ # keep torch lean in small containers
30
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
31
+ os.environ.setdefault("OMP_NUM_THREADS", "1")
32
+ os.environ.setdefault("MKL_NUM_THREADS", "1")
33
+ _embed_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
34
+ model_name=EMB_MODEL
35
+ )
36
+ print(f"[emb] Using {EMB_MODEL} (demo={_DEMO})")
37
+ return _embed_fn
38
+
39
+
40
+ # ---- OCR + parsing stack (isolated here) ----
41
+ import fitz # PyMuPDF
42
+ import docx # python-docx
43
+ from pdf2image import convert_from_bytes
44
+ from PIL import Image
45
+ import cv2, numpy as np
46
+
47
+ # -------------------------
48
+ # Small workspace utilities
49
+ # -------------------------
50
+ def _slug(s: str) -> str:
51
+ return re.sub(r"[^a-z0-9]+", "-", (s or "").strip().lower()).strip("-") or "default"
52
+
53
+ def user_prefix(email: str) -> str:
54
+ return f"u_{_slug(email)}__ws_"
55
+
56
+ def collection_name(email: Optional[str], workspace: str) -> str:
57
+ return f"{user_prefix(email)}{_slug(workspace)}" if email else f"ws_{_slug(workspace)}"
58
+
59
+ def list_projects_for_user(email: str) -> List[str]:
60
+ pref = user_prefix(email)
61
+ try:
62
+ cols = chroma.list_collections()
63
+ except Exception:
64
+ return []
65
+ out = []
66
+ for c in cols:
67
+ if c.name.startswith(pref):
68
+ out.append(c.name[len(pref):])
69
+ return sorted(set(out))
70
+
71
+ def delete_project(email: str, name: str) -> bool:
72
+ try:
73
+ chroma.delete_collection(f"{user_prefix(email)}{_slug(name)}")
74
+ return True
75
+ except Exception:
76
+ return False
77
+
78
+ # -------------------------
79
+ # Chroma helpers
80
+ # -------------------------
81
+ def get_collection(workspace: str, user: Optional[str] = None):
82
+ name = collection_name(user, workspace)
83
+ try:
84
+ # bind the embedding function even when getting an existing collection
85
+ return chroma.get_collection(name, embedding_function=get_embed_fn())
86
+ except Exception:
87
+ return chroma.create_collection(name=name, embedding_function=get_embed_fn())
88
+
89
+
90
+ def add_docs(col, docs: List[Tuple[str, str, Dict[str, Any]]]):
91
+ if not docs:
92
+ return
93
+ ids = [d[0] for d in docs]
94
+ texts = [d[1] for d in docs]
95
+ metas = [d[2] for d in docs]
96
+ col.add(ids=ids, documents=texts, metadatas=metas)
97
+
98
+ # def retrieve(col, query: str, k: int = 5) -> List[str]:
99
+ # if not query.strip():
100
+ # return []
101
+ # res = col.query(query_texts=[query], n_results=k)
102
+ # return res.get("documents", [[]])[0]
103
+
104
+ def retrieve(col, query: str, k: int = 5) -> List[str]:
105
+ docs, _, _, _ = retrieve_info(col, query, k)
106
+ return docs
107
+
108
+ def retrieve_info(col, query: str, k: int = 5):
109
+ """Return (docs, metadatas, distances, ids) for debugging/inspection."""
110
+ if not query.strip():
111
+ return [], [], [], []
112
+ res = col.query(
113
+ query_texts=[query],
114
+ n_results=k,
115
+ include=["documents", "metadatas", "distances"],
116
+ )
117
+ docs = res.get("documents", [[]])[0]
118
+ metas = res.get("metadatas", [[]])[0]
119
+ dists = res.get("distances", [[]])[0]
120
+ ids = res.get("ids", [[]])[0]
121
+ return docs, metas, dists, ids
122
+
123
+
124
+ # ---------- Retrieval quality utilities (NEW) ----------
125
+
126
+ from typing import Iterable
127
+ import math
128
+
129
+ def rrf_fuse(rank_lists: List[List[Tuple[str, Dict[str,Any], float, str]]], k: int = 60, K: int = 60):
130
+ """
131
+ rank_lists: list over sub-queries; each is [(doc, meta, dist, id), ...] sorted by DB rank.
132
+ Return fused unique list [(doc, meta, fused_score, id)] sorted desc by fused_score.
133
+ K is the RRF 'rank constant' (larger → flatter).
134
+ """
135
+ scores = {}
136
+ seen_meta = {}
137
+ for lst in rank_lists:
138
+ for r, (doc, meta, dist, _id) in enumerate(lst, start=1):
139
+ key = _id or (doc[:60] + "|" + (meta or {}).get("filename",""))
140
+ scores[key] = scores.get(key, 0.0) + 1.0 / (K + r)
141
+ if key not in seen_meta:
142
+ seen_meta[key] = (doc, meta, (1.0 - float(dist)) if dist is not None else None, _id)
143
+ fused = [(seen_meta[k][0], seen_meta[k][1], scores[k], seen_meta[k][3]) for k in scores]
144
+ fused.sort(key=lambda x: x[2], reverse=True)
145
+ return fused[:k]
146
+
147
+ def _embed_texts(texts: List[str]) -> List[List[float]]:
148
+ # reuse the same embedding fn as Chroma uses
149
+ ef = get_embed_fn()
150
+ return ef(texts)
151
+
152
+
153
+ from functools import lru_cache
154
+
155
+ @lru_cache(maxsize=1)
156
+ def _load_cross_encoder(model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"):
157
+ from sentence_transformers import CrossEncoder
158
+ return CrossEncoder(model_name)
159
+
160
+ def rerank_cross_encoder(query: str, cand_docs: List[Tuple[str,Dict[str,Any],float,str]],
161
+ model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2") -> List[Tuple[str,Dict[str,Any],float,str]]:
162
+ """
163
+ Return same tuples [(doc, meta, score, id)] but with cross-encoder similarity score, sorted desc.
164
+ Falls back to dot(query_emb, doc_emb) if CrossEncoder not available.
165
+ """
166
+ try:
167
+ ce = _load_cross_encoder(model_name)
168
+ pairs = [(query, d[0]) for d in cand_docs]
169
+ scores = ce.predict(pairs).tolist()
170
+ out = [(d[0], d[1], float(s), d[3]) for d, s in zip(cand_docs, scores)]
171
+ out.sort(key=lambda x: x[2], reverse=True)
172
+ return out
173
+ except Exception:
174
+ # fallback: cosine with same embedding model used for the DB
175
+ vecs = _embed_texts([query] + [d[0] for d in cand_docs])
176
+ qv, dvs = vecs[0], vecs[1:]
177
+ def _cos(a,b):
178
+ import numpy as _np
179
+ a = _np.array(a); b = _np.array(b)
180
+ denom = (float((a*a).sum())**0.5) * (float((b*b).sum())**0.5)
181
+ return float((a*b).sum()) / denom if denom else 0.0
182
+ out = [(d[0], d[1], _cos(qv, dv), d[3]) for d, dv in zip(cand_docs, dvs)]
183
+ out.sort(key=lambda x: x[2], reverse=True)
184
+ return out
185
+
186
+ def mmr_select(query: str, cand_docs: List[Tuple[str,Dict[str,Any],float,str]],
187
+ keep: int = 8, diversity_lambda: float = 0.7) -> List[Tuple[str,Dict[str,Any],float,str]]:
188
+ """
189
+ Maximal Marginal Relevance on the (already re-ranked) candidate list to keep diverse top-N.
190
+ """
191
+ vecs = _embed_texts([query] + [d[0] for d in cand_docs])
192
+ qv, dvs = vecs[0], vecs[1:]
193
+ import numpy as _np
194
+ def _cos(a,b):
195
+ a = _np.array(a); b = _np.array(b)
196
+ denom = (float((a*a).sum())**0.5) * (float((b*b).sum())**0.5)
197
+ return float((a*b).sum()) / denom if denom else 0.0
198
+
199
+ selected, rest = [], list(range(len(cand_docs)))
200
+ while rest and len(selected) < keep:
201
+ if not selected:
202
+ # start from the best already (list is sorted)
203
+ best = rest.pop(0)
204
+ selected.append(best)
205
+ continue
206
+ # pick argmax of λ*sim(query,di) - (1-λ)*max_j sim(di, dj_selected)
207
+ best_idx, best_score = None, -1e9
208
+ for i in rest:
209
+ rel = _cos(qv, dvs[i])
210
+ red = max(_cos(dvs[i], dvs[j]) for j in selected) if selected else 0.0
211
+ score = diversity_lambda * rel - (1.0 - diversity_lambda) * red
212
+ if score > best_score:
213
+ best_idx, best_score = i, score
214
+ rest.remove(best_idx)
215
+ selected.append(best_idx)
216
+ return [cand_docs[i] for i in selected]
217
+
218
+ def _normalize_where(where: Dict[str, Any]) -> Dict[str, Any]:
219
+ """
220
+ Chroma (newer) expects a single top-level operator: $and / $or / $not.
221
+ Convert a simple dict like {"filename":"x","doc_type":"pdf","page":2}
222
+ into {"$and":[{"filename":{"$eq":"x"}},{"doc_type":{"$eq":"pdf"}},{"page":{"$eq":2}}]}.
223
+ If already operator-structured, return as-is.
224
+ """
225
+ if not where:
226
+ return {}
227
+ # if already has a top-level operator, keep it
228
+ if any(str(k).startswith("$") for k in where.keys()):
229
+ return where
230
+ clauses = []
231
+ for k, v in where.items():
232
+ if v is None:
233
+ continue
234
+ if isinstance(v, dict) and any(str(op).startswith("$") for op in v.keys()):
235
+ clauses.append({k: v})
236
+ else:
237
+ clauses.append({k: {"$eq": v}})
238
+ return {"$and": clauses} if clauses else {}
239
+
240
+
241
+ def get_by_where(col, where: Dict[str, Any]) -> Tuple[List[str], List[Dict[str,Any]]]:
242
+ where = _normalize_where(where) # <-- add this line
243
+ res = col.get(where=where, include=["documents","metadatas"])
244
+ docs = res.get("documents") or []
245
+ metas = res.get("metadatas") or []
246
+ return docs, metas
247
+
248
+ def promote_parents(col, picked: List[Tuple[str,Dict[str,Any],float,str]], max_siblings_per_parent: int = 1) -> List[Tuple[str,Dict[str,Any],float,str]]:
249
+ """
250
+ For each picked child chunk, fetch siblings from the same parent (PDF page or DOCX section).
251
+ """
252
+ out: List[Tuple[str,Dict[str,Any],float,str]] = []
253
+ seen_ids = set()
254
+ for (doc, meta, score, _id) in picked:
255
+ out.append((doc, meta, score, _id))
256
+ parent_where = None
257
+ m = meta or {}
258
+ fn = m.get("filename") or m.get("source") or m.get("name")
259
+ if not fn:
260
+ continue
261
+ if m.get("doc_type") == "pdf" and m.get("page") is not None:
262
+ parent_where = {"filename": fn, "doc_type": "pdf", "page": m.get("page")}
263
+ elif m.get("doc_type") == "docx" and m.get("section_path"):
264
+ parent_where = {"filename": fn, "doc_type": "docx", "section_path": m.get("section_path")}
265
+ if not parent_where:
266
+ continue
267
+ sdocs, smetas = get_by_where(col, parent_where)
268
+ added = 0
269
+ for sd, sm in zip(sdocs, smetas):
270
+ sid = (sd[:60] + "|" + (sm or {}).get("filename","") + "|" + str((sm or {}).get("chunk")))
271
+ if sid in seen_ids:
272
+ continue
273
+ if added >= max_siblings_per_parent:
274
+ break
275
+ # avoid re-adding the very same child (best effort without global ids)
276
+ if sd.strip() == (doc or "").strip():
277
+ continue
278
+ out.append((sd, sm, score, sid))
279
+ seen_ids.add(sid)
280
+ added += 1
281
+ return out
282
+
283
+
284
+
285
+ # -------------------------
286
+ # OCR backends + parsers
287
+ # -------------------------
288
+ _PADDLE_OCR = None
289
+ def _get_paddle_ocr():
290
+ global _PADDLE_OCR
291
+ if _PADDLE_OCR is not None:
292
+ return _PADDLE_OCR
293
+ try:
294
+ from paddleocr import PaddleOCR
295
+ _PADDLE_OCR = PaddleOCR(use_angle_cls=True, lang="en")
296
+ except Exception as e:
297
+ _PADDLE_OCR = f"[paddle init error] {e}"
298
+ return _PADDLE_OCR
299
+
300
+ def ocr_image_paddle(img: Image.Image) -> str:
301
+ ocr = _get_paddle_ocr()
302
+ if isinstance(ocr, str):
303
+ return ocr
304
+ arr = np.array(img.convert("RGB"))[:, :, ::-1] # BGR
305
+ result = ocr.ocr(arr)
306
+ lines: List[str] = []
307
+ for page in result or []:
308
+ for item in page or []:
309
+ try:
310
+ _, (txt, _conf) = item
311
+ if txt:
312
+ lines.append(txt)
313
+ except Exception:
314
+ pass
315
+ return "\n".join(lines).strip()
316
+
317
+ def ocr_image_tesseract(img: Image.Image) -> str:
318
+ try:
319
+ import pytesseract
320
+ gray = cv2.cvtColor(np.array(img.convert("RGB")), cv2.COLOR_RGB2GRAY)
321
+ thr = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1]
322
+ return pytesseract.image_to_string(Image.fromarray(thr)).strip()
323
+ except Exception as e:
324
+ return f"[tesseract error] {e}"
325
+
326
+ def ocr_image(img: Image.Image, backend: str) -> str:
327
+ b = (backend or "auto").lower()
328
+ if b == "paddle":
329
+ out = ocr_image_paddle(img)
330
+ return out if out and not out.startswith("[paddle init error]") else ocr_image_tesseract(img)
331
+ if b == "tesseract":
332
+ return ocr_image_tesseract(img)
333
+ out = ocr_image_paddle(img)
334
+ return out if out and not out.startswith("[paddle init error]") else ocr_image_tesseract(img)
335
+
336
+ def parse_pdf(file_bytes: bytes, *, ocr_backend: str) -> str:
337
+ text_parts: List[str] = []
338
+ with fitz.open(stream=file_bytes, filetype="pdf") as doc:
339
+ for p in doc:
340
+ text_parts.append(p.get_text("text"))
341
+ text = "\n".join(text_parts).strip()
342
+ if text:
343
+ return text
344
+ pages = convert_from_bytes(file_bytes)
345
+ lines = [ocr_image(im, backend=ocr_backend) for im in pages]
346
+ return "\n".join(lines)
347
+
348
+ def parse_docx(file_bytes: bytes) -> str:
349
+ with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tmp:
350
+ tmp.write(file_bytes)
351
+ path = tmp.name
352
+ d = docx.Document(path)
353
+ os.unlink(path)
354
+ return "\n".join(p.text for p in d.paragraphs)
355
+
356
+ def parse_image(file_bytes: bytes, *, ocr_backend: str) -> str:
357
+ img = Image.open(io.BytesIO(file_bytes)).convert("RGB")
358
+ return ocr_image(img, backend=ocr_backend)
359
+
360
+ # -------------------------
361
+ # Hybrid chunking helpers (PDF pages + DOCX sections) ⬇️ ADD THIS
362
+ # -------------------------
363
+ def chunk_text(s: str, size: int = 1200, overlap: int = 180) -> List[str]:
364
+ """Slide a window over s with the given size/overlap, returning chunks."""
365
+ s = (s or "").strip()
366
+ out: List[str] = []
367
+ if not s:
368
+ return out
369
+ i = 0
370
+ n = len(s)
371
+ while i < n:
372
+ j = min(n, i + size)
373
+ out.append(s[i:j])
374
+ if j >= n:
375
+ break
376
+ i = max(0, j - overlap)
377
+ return out
378
+
379
+ def parse_pdf_pages(file_bytes: bytes, *, ocr_backend: str) -> List[str]:
380
+ """Return text per page (OCRing only pages that have no extractable text)."""
381
+ texts: List[str] = []
382
+ try:
383
+ with fitz.open(stream=file_bytes, filetype="pdf") as doc:
384
+ page_count = len(doc)
385
+ for idx in range(page_count):
386
+ t = (doc[idx].get_text("text") or "").strip()
387
+ if t:
388
+ texts.append(t)
389
+ else:
390
+ # OCR just this page to avoid rendering whole PDF into RAM
391
+ try:
392
+ imgs = convert_from_bytes(
393
+ file_bytes, first_page=idx + 1, last_page=idx + 1
394
+ )
395
+ t = ocr_image(imgs[0], backend=ocr_backend)
396
+ except Exception as e:
397
+ t = f"[ocr error p{idx+1}] {e}"
398
+ texts.append(t or "")
399
+ except Exception as e:
400
+ # fallback: keep previous parse_pdf behavior (may OCR all pages)
401
+ texts = (parse_pdf(file_bytes, ocr_backend=ocr_backend) or "").split("\n\f\n")
402
+ return texts
403
+
404
+ def parse_docx_sections(file_bytes: bytes) -> List[Tuple[str, str]]:
405
+ """Return (section_path, text) pairs using Heading 1/2/3 as boundaries.
406
+ If no headings exist, return a single ('Body', full_text).
407
+ """
408
+ with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tmp:
409
+ tmp.write(file_bytes)
410
+ path = tmp.name
411
+ d = docx.Document(path)
412
+ os.unlink(path)
413
+
414
+ def _lvl(name: Optional[str]) -> Optional[int]:
415
+ if not name:
416
+ return None
417
+ m = re.match(r"heading\s*(\d+)", name.strip().lower())
418
+ return int(m.group(1)) if m else None
419
+
420
+ sections: List[Tuple[str, str]] = []
421
+ stack: List[str] = [] # e.g., ["Intro", "Clocking"]
422
+ buf: List[str] = []
423
+
424
+ def _flush():
425
+ if buf:
426
+ sec = " > ".join(stack) if stack else "Body"
427
+ sections.append((sec, "\n".join(buf).strip()))
428
+ buf.clear()
429
+
430
+ for p in d.paragraphs:
431
+ txt = (p.text or "").strip()
432
+ lvl = _lvl(getattr(getattr(p, "style", None), "name", None))
433
+ if lvl and 1 <= lvl <= 3:
434
+ _flush()
435
+ # adjust heading path depth
436
+ if lvl == 1:
437
+ stack = [txt] if txt else []
438
+ elif lvl == 2:
439
+ stack = (stack[:1] + [txt]) if txt else stack[:1]
440
+ else: # lvl == 3
441
+ stack = (stack[:2] + [txt]) if txt else stack[:2]
442
+ else:
443
+ if txt:
444
+ buf.append(txt)
445
+ _flush()
446
+
447
+ if not sections:
448
+ whole = "\n".join(p.text for p in d.paragraphs).strip()
449
+ return [("Body", whole)]
450
+ return sections
451
+
452
+
453
+
454
+ # -------------------------
455
+ # Manage-PDF helpers
456
+ # -------------------------
457
+ def list_workspace_files_by_filename(ws: str, email: Optional[str]) -> List[str]:
458
+ if not ws or not email:
459
+ return []
460
+ col = get_collection(ws, email)
461
+ res = col.get(include=["metadatas"])
462
+ metadatas = res.get("metadatas") or []
463
+ names: List[str] = []
464
+ for m in metadatas:
465
+ m = m or {}
466
+ fn = m.get("source") or m.get("filename") or m.get("name")
467
+ if fn:
468
+ names.append(os.path.basename(str(fn)))
469
+ return sorted(set(names), key=str.lower)
470
+
471
+ def delete_files_by_filename(ws: str, email: Optional[str], filenames: List[str]) -> str:
472
+ if not ws:
473
+ return "Select a project first."
474
+ if not email:
475
+ return "You must be logged in."
476
+ if not filenames:
477
+ return "Nothing selected."
478
+ targets = {os.path.basename(str(n)) for n in filenames}
479
+ col = get_collection(ws, email)
480
+ res = col.get(include=["metadatas"])
481
+ metadatas = res.get("metadatas") or []
482
+ ids = res.get("ids") or []
483
+ to_delete_ids: List[str] = []
484
+ for _id, m in zip(ids, metadatas):
485
+ m = m or {}
486
+ fn = m.get("source") or m.get("filename") or m.get("name")
487
+ if fn and os.path.basename(str(fn)) in targets:
488
+ to_delete_ids.append(_id)
489
+ if not to_delete_ids:
490
+ return "No matching chunks found for the selected filename(s)."
491
+ col.delete(ids=to_delete_ids)
492
+ return f"Deleted {len(to_delete_ids)} chunk(s) from project '{ws}' across {len(targets)} file name(s)."
493
+
494
+ # -------------------------
495
+ # Reports helpers
496
+ # -------------------------
497
+ def list_summary_reports() -> List[str]:
498
+ roots = [os.getcwd(), tempfile.gettempdir()]
499
+ out: List[str] = []
500
+ for root in roots:
501
+ out.extend(os.path.abspath(p) for p in glob.glob(os.path.join(root, "hwqa_summary_*.md")))
502
+ out.sort(key=lambda p: os.path.getmtime(p), reverse=True)
503
+ return out
504
+
505
+ def read_report_text(path: str) -> str:
506
+ if not path:
507
+ return "_No report selected._"
508
+ if not os.path.isfile(path):
509
+ return f"_Report not found:_ `{path}`"
510
+ try:
511
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
512
+ return f.read()
513
+ except Exception as e:
514
+ return f"_Could not read report (`{path}`):_ {e}"
515
+
516
+ # -------------------------
517
+ # Trace helpers
518
+ # -------------------------
519
+ def trace_push(state: Dict[str, Any], node: str, t0: float, notes: str = "") -> None:
520
+ try:
521
+ ms = int((time.perf_counter() - t0) * 1000)
522
+ except Exception:
523
+ ms = 0
524
+ trace = state.get("trace") or []
525
+ trace.append({"node": node, "ms": ms, "notes": notes})
526
+ state["trace"] = trace
527
+
528
+ def _trace_to_markdown(state: Dict[str, Any]) -> str:
529
+ q = (state.get("user_input") or "").strip()
530
+ full = state.get("trace") or []
531
+
532
+ # keep only entries from the last 'session' marker onward
533
+ last_idx = -1
534
+ for i, e in enumerate(full):
535
+ if (e or {}).get("node") == "session":
536
+ last_idx = i
537
+ trace = full[last_idx:] if last_idx != -1 else full
538
+
539
+ head = [
540
+ "### Last request",
541
+ f"**Q:** {q}" if q else "**Q:** (no question text)",
542
+ f"Workspace: {state.get('workspace','')} OCR: {state.get('ocr_backend','')} Web search: {state.get('do_web_search')} Schematic mode: {state.get('treat_images_as_schematics')}",
543
+ "",
544
+ ]
545
+ body = []
546
+ for i, e in enumerate(trace or [], 1):
547
+ body.append(f"{i}. **{e.get('node','?')}** — {e.get('notes','')} ({e.get('ms',0)} ms)")
548
+ cites = []
549
+ # Show citations only if the web node actually ran and returned sources>0
550
+ # (prevents lingering/irrelevant links when web search is disabled)
551
+ import re as _re
552
+
553
+ # work on the sliced "trace" we already computed above
554
+ web_sources = 0
555
+ for e in trace or []:
556
+ if (e or {}).get("node") == "web":
557
+ notes = (e or {}).get("notes", "")
558
+ m = _re.search(r"sources\s*=\s*(\d+)", notes)
559
+ if m:
560
+ try:
561
+ web_sources = int(m.group(1))
562
+ except Exception:
563
+ web_sources = 0
564
+
565
+ if web_sources > 0:
566
+ cites = [f"- {u}" for u in (state.get("citations") or [])]
567
+ foot = ["", "**Citations (web):**", *cites]
568
+ else:
569
+ foot = []
570
+
571
+
572
+ # ---- LLM Inspector (NEW) ----
573
+ events = state.get("llm_events") or []
574
+ inspector: List[str] = []
575
+ if events:
576
+ inspector.extend(["", "### LLM Inspector", "",
577
+ "| node | model | ms | in | out | total |",
578
+ "|---|---|---:|---:|---:|---:|"])
579
+ for e in events:
580
+ inspector.append(
581
+ f"| {e.get('node','')} | {e.get('model','')} | {e.get('latency_ms',0)} | "
582
+ f"{e.get('prompt_tokens',0)} | {e.get('completion_tokens',0)} | {e.get('total_tokens',0)} |"
583
+ )
584
+ inspector.append("")
585
+ inspector.append("<details><summary>Show input/output previews</summary>")
586
+ for e in events:
587
+ inspector.append(
588
+ f"<br/><b>{e.get('node','')}</b> — <i>{e.get('model','')}</i><br/>"
589
+ f"<b>Input:</b> {e.get('prompt_preview','')}<br/>"
590
+ f"<b>Output:</b> {e.get('output_preview','')}"
591
+ )
592
+ inspector.append("</details>")
593
+ # ---- Retrieval Inspector (NEW) ----
594
+ r_events = state.get("retrieval_events") or []
595
+ retr_tbl: List[str] = []
596
+ if r_events:
597
+ retr_tbl.extend(["", "### Retrieval Inspector", "",
598
+ "| rank | score | file | type | page/section | preview |",
599
+ "|---:|---:|---|---|---|---|"])
600
+ for e in r_events:
601
+ loc = ""
602
+ if e.get("page") is not None:
603
+ loc = f"p{e.get('page')}"
604
+ elif e.get("section"):
605
+ loc = e.get("section") or ""
606
+ # escape pipes in preview
607
+ prev = (e.get("preview","") or "").replace("|", "\\|")
608
+ score = "" if e.get("score") is None else f"{e.get('score'):.3f}"
609
+ retr_tbl.append(f"| {e.get('rank','')} | {score} | {e.get('filename','')} | "
610
+ f"{e.get('doc_type','')} | {loc} | {prev} |")
611
+
612
+ # ---- Executed Nodes Summary + Execution Narrative (NEW) ----
613
+ trace_list = state.get("trace") or []
614
+ ran = [e for e in trace_list if e.get("node")]
615
+ ran_names = [e.get("node") for e in ran]
616
+
617
+ # helpers to pick info from state
618
+ def _llm_line(node_name: str):
619
+ for e in (state.get("llm_events") or []):
620
+ if e.get("node") == node_name:
621
+ m = e.get("model") or ""
622
+ pi = e.get("prompt_tokens") or 0
623
+ co = e.get("completion_tokens") or 0
624
+ ms = e.get("latency_ms") or 0
625
+ return f"model={m} in={pi} out={co} {int(ms)}ms"
626
+ return ""
627
+
628
+ def _retrieve_line():
629
+ evs = state.get("retrieval_events") or []
630
+ if not evs:
631
+ return "hits=0"
632
+ top = ", ".join([f"{(e.get('filename') or '')} {('p'+str(e['page'])) if e.get('page') else (e.get('section') or '')}".strip()
633
+ for e in evs[:3]])
634
+ return f"hits={len(evs)} ({top})"
635
+
636
+ summary_lines: List[str] = []
637
+ for idx, e in enumerate(ran, 1):
638
+ n = e.get("node", "?")
639
+ notes = e.get("notes", "")
640
+ if n == "router":
641
+ route = state.get("route") or "?"
642
+ files = len(state.get("parsed_docs") or [])
643
+ schem = bool(state.get("has_schematic"))
644
+ summary_lines.append(f"{idx}. **router** — route={route} (files={files}, schematic={schem})")
645
+ elif n == "ingest":
646
+ summary_lines.append(f"{idx}. **ingest** — {notes or 'done'}")
647
+ elif n == "retrieve":
648
+ # Legacy path (older runs) — keep this
649
+ k = len(state.get("retrieved_chunks") or [])
650
+ summary_lines.append(f"{idx}. **retrieve** — returned {k} chunks")
651
+ elif n in ("multiquery","retrieve_pool","rrf_fuse","rerank","mmr","parent_promote"):
652
+ if n == "multiquery":
653
+ summary_lines.append(f"{idx}. **multiquery** — generated variants")
654
+ elif n == "retrieve_pool":
655
+ summary_lines.append(f"{idx}. **retrieve_pool** — pooled top-k per variant")
656
+ elif n == "rrf_fuse":
657
+ summary_lines.append(f"{idx}. **rrf_fuse** — fused & deduped")
658
+ elif n == "rerank":
659
+ summary_lines.append(f"{idx}. **rerank** — cross-encoder re-ordered")
660
+ elif n == "mmr":
661
+ summary_lines.append(f"{idx}. **mmr** — selected diverse top-N")
662
+ elif n == "parent_promote":
663
+ # New pipeline — final point where retrieved_chunks is set
664
+ k = len(state.get("retrieved_chunks") or [])
665
+ summary_lines.append(f"{idx}. **parent_promote** — finalized {k} chunks for context")
666
+
667
+ elif n == "web":
668
+ ws = state.get("web_snippets") or []
669
+ summary_lines.append(f"{idx}. **web** — sources={len(ws)}")
670
+ elif n in ("answer", "schematic_answer"):
671
+ summary_lines.append(f"{idx}. **{n}** — {_llm_line(n)}")
672
+ elif n == "inventory_docs":
673
+ summary_lines.append(f"{idx}. **inventory_docs** — {notes or 'listed PDFs/DOCX'}")
674
+ elif n == "inventory_schematics":
675
+ summary_lines.append(f"{idx}. **inventory_schematics** — {notes or 'listed schematics'}")
676
+ elif n == "verify":
677
+ summary_lines.append(f"{idx}. **verify** — {notes or 'ok'}")
678
+ else:
679
+ summary_lines.append(f"{idx}. **{n}** — {notes}")
680
+
681
+ # Build a one-paragraph narrative
682
+ narrative_bits: List[str] = []
683
+ if "router" in ran_names:
684
+ narrative_bits.append(f"The router selected the **{state.get('route') or '?'}** path.")
685
+ if "ingest" in ran_names:
686
+ narrative_bits.append("Ingest parsed and indexed the new files.")
687
+ if "retrieve" in ran_names:
688
+ evs = state.get("retrieval_events") or []
689
+ narrative_bits.append(f"Retrieval returned **{len(evs)}** chunk(s).")
690
+ if "web" in ran_names:
691
+ ws = state.get("web_snippets") or []
692
+ if ws:
693
+ narrative_bits.append(f"Web search added **{len(ws)}** source(s).")
694
+ else:
695
+ narrative_bits.append("No web sources were needed.")
696
+ if "answer" in ran_names or "schematic_answer" in ran_names:
697
+ # use whichever LLM event we have
698
+ llms = state.get("llm_events") or []
699
+ if llms:
700
+ m = llms[-1].get("model", "")
701
+ pi = llms[-1].get("prompt_tokens") or 0
702
+ co = llms[-1].get("completion_tokens") or 0
703
+ narrative_bits.append(f"The model **{m}** generated the answer ({pi} prompt → {co} completion tokens).")
704
+ if "verify" in ran_names:
705
+ narrative_bits.append("Verification passed.")
706
+
707
+ summary_block: List[str] = []
708
+ if summary_lines:
709
+ summary_block.extend(["", "### What happened in this run", ""])
710
+ summary_block.extend([*summary_lines, ""])
711
+ if narrative_bits:
712
+ summary_block.extend(["**Execution narrative:** " + " ".join(narrative_bits), ""])
713
+
714
+ # ---- Raw JSON collapsed (NEW) ----
715
+ raw = {"trace": trace_list}
716
+ raw_json = json.dumps(raw, indent=2, ensure_ascii=False)
717
+ raw_block = [
718
+ "",
719
+ "<details><summary><b>Show raw trace JSON</b></summary>",
720
+ "",
721
+ "```json",
722
+ raw_json,
723
+ "```",
724
+ "",
725
+ "</details>",
726
+ ]
727
+
728
+ return "\n".join(head + body + inspector + retr_tbl + summary_block + raw_block + foot)
729
+
730
+ def _dag_image_from_trace(trace: list, llm_events: list = None):
731
+ import networkx as nx
732
+ import matplotlib.pyplot as plt
733
+ from PIL import Image as PILImage
734
+ import io as _io
735
+
736
+ G = nx.DiGraph()
737
+
738
+ nodes = [
739
+ "session", "router", "ingest",
740
+ "multiquery","retrieve_pool","rrf_fuse","rerank","mmr","parent_promote",
741
+ "web", "answer", "verify", "clarify", "report", "END",
742
+ "parts_expand", "schematic_answer",
743
+ "inventory_docs", "inventory_schematics"
744
+ ]
745
+ G.add_nodes_from(nodes)
746
+
747
+ G.add_edges_from([
748
+ ("session","router"), ("session","report"),
749
+ ("router","ingest"), ("router","multiquery"),
750
+ ("ingest","multiquery"),
751
+ ("multiquery","retrieve_pool"),
752
+ ("retrieve_pool","rrf_fuse"),
753
+ ("rrf_fuse","rerank"),
754
+ ("rerank","mmr"),
755
+ ("mmr","parent_promote"),
756
+
757
+ ("parent_promote","web"),
758
+ ("parent_promote","answer"),
759
+ ("web","answer"),
760
+ ("answer","verify"),
761
+ ("parent_promote","parts_expand"),
762
+ ("parts_expand","schematic_answer"),
763
+ ("schematic_answer","verify"),
764
+ ("verify","clarify"),
765
+ ("verify","END"),
766
+ ("parent_promote","inventory_docs"),
767
+ ("parent_promote","inventory_schematics"),
768
+ ("inventory_docs","verify"),
769
+ ("inventory_schematics","verify"),
770
+ ])
771
+
772
+ pos = {
773
+ "session": (0.0, 0.0),
774
+ "router": (1.2, 0.0),
775
+ "ingest": (2.4, 0.6),
776
+ "multiquery": (3.6, 0.6),
777
+ "retrieve_pool": (4.8, 0.6),
778
+ "rrf_fuse": (6.0, 0.6),
779
+ "rerank": (7.2, 0.6),
780
+ "mmr": (8.4, 0.6),
781
+ "parent_promote": (9.6, 0.6),
782
+
783
+ "web": (10.8, 1.0),
784
+ "answer": (12.0, 0.6),
785
+ "verify": (13.2, 0.6),
786
+ "clarify":(14.4, 1.0),
787
+ "report": (2.4, -1.0),
788
+ "END": (14.4, 0.2),
789
+
790
+ "parts_expand": (10.8, 0.0),
791
+ "schematic_answer": (12.0, 0.2),
792
+ "inventory_docs": (10.8, 0.4),
793
+ "inventory_schematics":(10.8, -0.4),
794
+ }
795
+
796
+ # Map latest LLM event per node
797
+ ev_map = {}
798
+ for e in (llm_events or []):
799
+ ev_map[e.get("node")] = e
800
+
801
+ # Highlight executed path
802
+ path_nodes = [e.get("node") for e in (trace or []) if e.get("node") in G.nodes]
803
+ path_edges = [(a, b) for a, b in zip(path_nodes, path_nodes[1:]) if G.has_edge(a, b)]
804
+
805
+ node_colors = ["#90caf9" if n in path_nodes else "#e6e6e6" for n in G.nodes]
806
+ node_sizes = [1800 if n in path_nodes else 1200 for n in G.nodes]
807
+ edge_colors = ["#1976d2" if e in path_edges else "#bdbdbd" for e in G.edges]
808
+ widths = [3.0 if e in path_edges else 1.5 for e in G.edges]
809
+
810
+ # Labels with subtitle if LLM used
811
+ labels = {}
812
+ for n in nodes:
813
+ if n in ev_map:
814
+ e = ev_map[n]
815
+ labels[n] = f"{n}\n{e.get('model','')} · {e.get('prompt_tokens',0)}→{e.get('completion_tokens',0)} tok"
816
+ else:
817
+ labels[n] = n
818
+
819
+ fig = plt.figure(figsize=(11, 3.6))
820
+ nx.draw(G, pos, with_labels=False, arrows=True,
821
+ node_color=node_colors, node_size=node_sizes,
822
+ edge_color=edge_colors, width=widths)
823
+ nx.draw_networkx_labels(G, pos, labels=labels, font_size=9)
824
+ plt.axis("off")
825
+ buf = _io.BytesIO()
826
+ plt.tight_layout(); plt.savefig(buf, format="png", dpi=150); plt.close(fig)
827
+ buf.seek(0)
828
+ return PILImage.open(buf)
829
+
830
+ def render_trace_artifacts(state: Dict[str, Any]):
831
+ md = _trace_to_markdown(state)
832
+ json_str = __import__("json").dumps(state.get("trace") or [], indent=2)
833
+
834
+ # last-turn slice for the DAG too
835
+ full = state.get("trace") or []
836
+ last_idx = -1
837
+ for i, e in enumerate(full):
838
+ if (e or {}).get("node") == "session":
839
+ last_idx = i
840
+ last_trace = full[last_idx:] if last_idx != -1 else full
841
+
842
+ try:
843
+ img = _dag_image_from_trace(last_trace, state.get("llm_events") or [])
844
+ except Exception:
845
+ img = None
846
+ return md, json_str, img
847
+
hardware_qa_assistant_lang_graph_gradio_demo_version_users.py ADDED
@@ -0,0 +1,1599 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # hardware_qa_assistant_lang_graph_gradio.py — Advanced Hardware QA Assistant
2
+ # ---------------------------------------------------------------------------------
3
+ # What this script demonstrates:
4
+ # • A real multi‑node LangGraph with conditional routing and checkpoints
5
+ # • Multimodal ingestion (PDF/DOCX/Images → OCR) decoupled from Q&A
6
+ # • Persistent, per‑workspace RAG (Chroma) with local Sentence‑Transformers embeddings
7
+ # • Optional online search (Tavily) fused with RAG, with citations
8
+ # • Answer verification (heuristic) + targeted clarification branch
9
+ # • Explicit session termination that generates a Markdown report (downloadable)
10
+ # • Provider abstraction using OpenAI‑compatible API (Together primary, Groq hook ready)
11
+ #
12
+ # Run steps:
13
+ # 1) pip install -r requirements.txt
14
+ # 2) Copy .env.example → .env and set at least:
15
+ # OPENAI_API_KEY=your_together_key
16
+ # OPENAI_BASE_URL=https://api.together.xyz/v1
17
+ # 3) python hardware_qa_assistant_lang_graph_gradio.py → open the Gradio link
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import io
22
+ import json
23
+ import tempfile
24
+ from typing import TypedDict, List, Dict, Any, Tuple, Optional
25
+ import time
26
+ import re, urllib.parse
27
+ from jose import jwt
28
+
29
+ from dotenv import load_dotenv
30
+ load_dotenv()
31
+
32
+ # LangGraph core
33
+ from langgraph.graph import StateGraph, END
34
+ from langgraph.checkpoint.memory import MemorySaver
35
+
36
+ # HTTP + UI
37
+ import requests
38
+ # Groq SDK
39
+ from groq import Groq
40
+ import gradio as gr
41
+
42
+ import contextvars
43
+ _progress_var = contextvars.ContextVar("progress_hook", default=None)
44
+
45
+ # Retrieval pipeline knobs (NEW)
46
+ MQ_N = 10 # how many sub-queries to generate
47
+ POOL_K_EACH = 40 # top-k per sub-query from Chroma
48
+ FUSE_K = 60 # keep this many after RRF fusion (before rerank)
49
+ MMR_KEEP = 8 # final number of chunks passed to the LLM
50
+ MMR_LAMBDA = 0.7 # relevance vs diversity trade-off
51
+ PROMOTE_SIBLINGS = 1 # siblings per parent (controls token growth)
52
+ RERANK_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
53
+
54
+ def _set_progress_hook(h): _progress_var.set(h)
55
+ def _get_progress_hook(): return _progress_var.get()
56
+
57
+ from hardware_qa_assistant_helpers import (
58
+ # RAG + workspace
59
+ get_collection, add_docs, retrieve, retrieve_info, list_projects_for_user, delete_project,
60
+ # Manage PDFs
61
+ list_workspace_files_by_filename, delete_files_by_filename,
62
+ # Parsers
63
+ parse_image, parse_pdf_pages, parse_docx_sections, chunk_text,
64
+ # Reports
65
+ list_summary_reports, read_report_text,
66
+ # Retrieval quality utils ← add these
67
+ rrf_fuse, rerank_cross_encoder, mmr_select, promote_parents,
68
+ # Trace
69
+ trace_push, render_trace_artifacts,
70
+ )
71
+
72
+ # --- Compact uploader CSS (Option 2) ---
73
+ UPLOADER_CSS = """
74
+ /* keep the dropzone narrow */
75
+ #uploader { max-width: 520px; }
76
+
77
+ /* trim vertical padding around the dropzone */
78
+ #uploader .gradio-container,
79
+ #uploader .container,
80
+ #uploader > div { padding-top: 0 !important; padding-bottom: 0 !important; }
81
+
82
+ /* clamp the drop area height (selectors vary by gradio version, include both) */
83
+ #uploader .h-full,
84
+ #uploader [data-testid="file"] { min-height: 80px !important; height: 80px !important; }
85
+
86
+ /* keep preview small but scrollable if many files */
87
+ #uploader .file-preview,
88
+ #uploader .file-preview-box { max-height: 50px; overflow: auto; }
89
+
90
+ /* ===== User bubble text color (make visible on white bubble) ===== */
91
+ #chatui .message.user,
92
+ #chatui [data-testid="user"],
93
+ #chatui .chat-message.user,
94
+ #chatui .user {
95
+ color: #0d47a1 !important; /* dark blue text */
96
+ }
97
+
98
+ /* Make everything inside inherit the same color (stronger than theme rules) */
99
+ #chatui .message.user * { color: inherit !important; }
100
+
101
+ /* Optional: links slightly darker so they still look like links */
102
+ #chatui .message.user a { color: #0a3d91 !important; }
103
+ /* Ensure Markdown wrapper used by Gradio adopts the blue color, too */
104
+ #chatui .message.user .prose,
105
+ #chatui .message.user .prose :is(p, span, li, strong, em, h1, h2, h3, h4, h5, h6) {
106
+ color: #0d47a1 !important;
107
+ }
108
+
109
+ /* Code blocks/inline code also visible on white bubble */
110
+ #chatui .message.user :is(code, pre) {
111
+ color: #0d47a1 !important;
112
+ background: rgba(13, 71, 161, 0.08) !important; /* subtle tint so code stands out */
113
+ border-color: rgba(13, 71, 161, 0.18) !important;
114
+ }
115
+
116
+ /* Blockquotes inherit the blue and show a matching left border */
117
+ #chatui .message.user blockquote {
118
+ color: #0d47a1 !important;
119
+ border-left-color: #0d47a1 !important;
120
+ }
121
+ /* Chat tab: make the “User: user@example.com” line yellow (and its link) */
122
+ #user_label,
123
+ #user_label a,
124
+ #user_label .prose,
125
+ #user_label .prose a {
126
+ color: #ffeb3b !important; /* bright yellow */
127
+ text-decoration-color: #ffeb3b !important;
128
+ }
129
+
130
+ /* Trace tab: make web citation links yellow so they’re readable */
131
+ #trace_md a,
132
+ #trace_md .prose a,
133
+ #trace_md a:visited,
134
+ #trace_md .prose a:visited {
135
+ color: #ffeb3b !important;
136
+ text-decoration-color: #ffeb3b !important;
137
+ }
138
+
139
+ /* compact the schematic checkbox */
140
+ #schem_tick, #schem_tick > div { padding-top: 0 !important; padding-bottom: 0 !important; }
141
+ #schem_tick * { margin-top: 0 !important; margin-bottom: 0 !important; }
142
+ #schem_tick label { font-size: 14px !important; line-height: 1.1 !important; }
143
+ #send_btn { margin-top: 8px !important; margin-bottom: 6px !important; }
144
+
145
+ """
146
+
147
+ # Web search (optional)
148
+ try:
149
+ from tavily import TavilyClient
150
+ except Exception: # allow running without tavily installed
151
+ TavilyClient = None # type: ignore
152
+
153
+ # =====================
154
+ # Config (from .env; overridable via the UI)
155
+ # =====================
156
+ # OpenAI-compatible env (Groq only)
157
+ PROVIDER = os.getenv("LLM_PROVIDER", "groq").lower() # groq
158
+ GROQ_KEY = os.getenv("GROQ_API_KEY", "")
159
+ #MODEL_G = os.getenv("LLM_MODEL_GROQ", "llama-3.1-70b-versatile")
160
+ MODEL_G = os.getenv("LLM_MODEL_GROQ", "openai/gpt-oss-120b")
161
+ # Vision (Groq Scout, image understanding)
162
+ VISION_MODEL_T = os.getenv("VISION_MODEL_GROQ", "meta-llama/llama-4-scout-17b-16e-instruct")
163
+
164
+
165
+
166
+ OCR_BACKEND = os.getenv("OCR_BACKEND", "paddle").lower() # paddle | tesseract | auto
167
+ CHROMA_DIR = os.getenv("CHROMA_DIR", ".chroma_hwqa_workspace")
168
+ DEFAULT_WS = os.getenv("WORKSPACE", "default")
169
+ TAVILY_KEY = os.getenv("TAVILY_API_KEY", "")
170
+ DEFAULT_LANGUAGE = "English" # could be auto or Greek if desired
171
+ JWT_LEEWAY_SEC = int(os.getenv("JWT_LEEWAY_SEC", "900"))
172
+
173
+ import re
174
+ _URL_RE = re.compile(r"https?://[^\s)\]]+", flags=re.I)
175
+ _SOURCES_RE = re.compile(r"(?is)\n+(?:sources?|references?)\s*:.*$", flags=re.I)
176
+
177
+ def sanitize_answer(
178
+ text: str,
179
+ allowed_urls: List[str],
180
+ *,
181
+ append_refs: bool = True,
182
+ title: str = "Citations (web)"
183
+ ) -> str:
184
+ """
185
+ 1) Remove hallucinated 'Sources/References' blocks.
186
+ 2) Strip any raw URLs not in allowed_urls.
187
+ 3) Optionally append a vetted citations list so links show in the Chat.
188
+ """
189
+ if not text:
190
+ return text
191
+
192
+ out = _SOURCES_RE.sub("", (text or "").strip())
193
+
194
+ if allowed_urls:
195
+ allow = tuple(allowed_urls)
196
+ def _sub(m):
197
+ u = m.group(0)
198
+ return u if u.startswith(allow) else "" # keep only vetted URLs
199
+ out = _URL_RE.sub(_sub, out)
200
+
201
+ if append_refs:
202
+ refs = "\n".join(f"[{i+1}] {u}" for i, u in enumerate(allowed_urls, start=1))
203
+ out = f"{out}\n\n**{title}:**\n{refs}"
204
+ else:
205
+ # No vetted links for this turn → remove any raw URLs the model emitted
206
+ out = _URL_RE.sub("", out)
207
+
208
+ return out.strip()
209
+
210
+ # Optional Tavily client (clean SERP API with free tier)
211
+ tavily = TavilyClient(api_key=TAVILY_KEY) if (TAVILY_KEY and TavilyClient) else None
212
+
213
+ # =====================
214
+ # Provider‑agnostic Chat wrapper via OpenAI‑compatible endpoints
215
+ # =====================
216
+
217
+ def chat_llm(messages: List[Dict[str, Any]], *, provider: str, model: str, max_tokens: int = 3000, temperature: float = 0.2) -> Tuple[str, Dict[str, Any]]:
218
+ """Minimal wrapper around Groq using OpenAI‑compatible /chat/completions.
219
+ - messages: OpenAI-style list[{role, content}]
220
+ - provider: 'together' or 'groq'
221
+ - model: model id for that provider
222
+ """
223
+ provider = provider.lower()
224
+ # Groq OpenAI‑compatible endpoint
225
+ if not GROQ_KEY:
226
+ return "[LLM error] GROQ_API_KEY missing; switch provider or set key.", {}
227
+ base = "https://api.groq.com/openai/v1"
228
+ url = f"{base}/chat/completions"
229
+ headers = {"Authorization": f"Bearer {GROQ_KEY}", "Content-Type": "application/json"}
230
+
231
+ payload = {
232
+ "model": model,
233
+ "messages": messages,
234
+ "max_tokens": max_tokens,
235
+ "temperature": temperature,
236
+ }
237
+
238
+ try:
239
+ r = requests.post(url, headers=headers, data=json.dumps(payload), timeout=300)
240
+ if r.status_code >= 400:
241
+ return f"[LLM error] {r.status_code} {r.reason}: {r.text}", {}
242
+ data = r.json()
243
+ reply = (data["choices"][0]["message"]["content"] or "").strip()
244
+ usage = data.get("usage") or {}
245
+ return reply, usage
246
+ except Exception as e:
247
+ return f"[LLM error] {e}", {}
248
+
249
+ def call_vision_extract(image_bytes: bytes, *, model: Optional[str] = None) -> Tuple[Optional[dict], str, str, Optional[dict]]:
250
+ """
251
+ Call Groq 'meta-llama/llama-4-scout-17b-16e-instruct' for schematic understanding.
252
+ Returns (json_obj, summary_text, raw_text, usage_dict). On error, json_obj=None and summary_text starts with [vision error].
253
+ """
254
+ api_key = os.getenv("GROQ_API_KEY") or GROQ_KEY
255
+ if not api_key:
256
+ return None, "[vision error] missing GROQ_API_KEY", "", {}
257
+
258
+ import base64, re
259
+ b64 = base64.b64encode(image_bytes).decode("utf-8")
260
+ data_url = f"data:image/jpeg;base64,{b64}"
261
+
262
+ # Keep your existing strict-JSON + 'summary:' prompt contract,
263
+ # so downstream parsing remains unchanged.
264
+ prompt = (
265
+ "You are an EE expert. From the schematic IMAGE, output STRICT JSON first, "
266
+ "then exactly one line starting with 'summary:' (1–3 sentences).\n"
267
+ "JSON schema:\n"
268
+ "{\n"
269
+ ' "components":[{"ref":"U1","label":"PI6C48545LE","type":"IC","pins":["CLK0","CLK1","OE","VCC1","GND1"]}],\n'
270
+ ' "nets":[{"name":"REFCLK_2G5","connections":[{"ref":"U1","pin":"Q0"},{"ref":"J1","pin":"P1"}]}],\n'
271
+ ' "texts":["3.3V","100nF","OE","CLK_SEL"]\n'
272
+ "}\n"
273
+ "Rules: JSON ONLY as the first output (no prose). Then one 'summary:' line."
274
+ )
275
+
276
+ try:
277
+ client = Groq(api_key=api_key)
278
+ completion = client.chat.completions.create(
279
+ model=(model or VISION_MODEL_T), # now defaults to Groq Scout
280
+ temperature=0.1,
281
+ max_completion_tokens=1500,
282
+ messages=[
283
+ {"role": "user", "content": [
284
+ {"type": "text", "text": prompt},
285
+ {"type": "image_url", "image_url": {"url": data_url}},
286
+ ]},
287
+ ],
288
+ )
289
+ text = (completion.choices[0].message.content or "").strip()
290
+ usage = {}
291
+ try:
292
+ # Groq returns usage fields; guard them defensively.
293
+ u = getattr(completion, "usage", None)
294
+ if u:
295
+ usage = {
296
+ "prompt_tokens": getattr(u, "prompt_tokens", None),
297
+ "completion_tokens": getattr(u, "completion_tokens", None),
298
+ "total_tokens": getattr(u, "total_tokens", None),
299
+ }
300
+ except Exception:
301
+ usage = {}
302
+ except Exception as e:
303
+ return None, f"[vision error] {e}", "", {}
304
+
305
+ # Parse JSON block + 'summary:' line (unchanged)
306
+ j = None
307
+ try:
308
+ start = text.find("{"); end = text.rfind("}")
309
+ if start != -1 and end != -1 and end > start:
310
+ j = json.loads(text[start:end+1])
311
+ except Exception:
312
+ j = None
313
+
314
+ m = re.search(r"summary\s*:\s*(.+)", text, flags=re.IGNORECASE | re.DOTALL)
315
+ summary = (m.group(1).strip() if m else "") or text[:800]
316
+ return j, summary, text, usage
317
+
318
+
319
+ # =====================
320
+ # User & workspace helpers (per-user collections)
321
+ # =====================
322
+ AUTH_SECRET = os.getenv("AUTH_SECRET", "change-me")
323
+ JWT_ALG = "HS256"
324
+
325
+ def _slug(s: str) -> str:
326
+ return re.sub(r'[^a-z0-9]+','-', (s or '').strip().lower()).strip('-') or 'default'
327
+
328
+
329
+ def get_logged_in_email_from_request(request: gr.Request) -> Optional[str]:
330
+ """
331
+ Return logged-in email. First try the readable 'who' cookie (display only),
332
+ then decode the HttpOnly 'session' JWT using AUTH_SECRET.
333
+ Works across Gradio versions: request.cookies or Cookie header fallback.
334
+ """
335
+ # 1) Non-HttpOnly display cookie (simple path)
336
+ try:
337
+ if hasattr(request, "cookies") and isinstance(request.cookies, dict):
338
+ who = request.cookies.get("who")
339
+ if who:
340
+ return who
341
+ except Exception:
342
+ pass
343
+
344
+ # 2) HttpOnly JWT cookie
345
+ tok = None
346
+ try:
347
+ if hasattr(request, "cookies") and isinstance(request.cookies, dict):
348
+ tok = request.cookies.get("session")
349
+ except Exception:
350
+ tok = None
351
+
352
+ # 3) Fallback: parse Cookie header
353
+ if not tok:
354
+ cookie_hdr = request.headers.get("cookie", "")
355
+ m = re.search(r"(?:^|;)\s*session=([^;]+)", cookie_hdr, flags=re.IGNORECASE)
356
+ tok = urllib.parse.unquote(m.group(1)) if m else None
357
+
358
+ if not tok:
359
+ return None
360
+
361
+ try:
362
+ import time # ensure available
363
+ # Verify signature, but skip built-in exp check
364
+ payload = jwt.decode(
365
+ tok,
366
+ AUTH_SECRET,
367
+ algorithms=[JWT_ALG],
368
+ options={"verify_exp": False}, # jose here also lacks 'leeway' kw
369
+ )
370
+ # Manual leeway on exp
371
+ exp = payload.get("exp")
372
+ if isinstance(exp, (int, float)):
373
+ now = int(time.time())
374
+ if now > int(exp) + JWT_LEEWAY_SEC:
375
+ return None
376
+ return payload.get("sub")
377
+ except Exception:
378
+ return None
379
+
380
+
381
+ # =====================
382
+ # State — explicit fields make the graph easier to reason about and demo
383
+ # =====================
384
+ class State(TypedDict, total=False):
385
+ # Configuration & context
386
+ thread_id: str # unique run id (used for report filename)
387
+ history: List[Dict[str, str]]
388
+ workspace: str # persistent RAG namespace
389
+ provider: str # together | groq
390
+ model: str # model name for provider
391
+ ocr_backend: str # paddle | tesseract | auto
392
+ do_web_search: bool # enable Tavily fusion
393
+
394
+ # Current input
395
+ user_input: str
396
+ files: List[Dict[str, Any]] # [{name, bytes}]
397
+ retrieval_events: List[Dict[str, Any]]
398
+
399
+ # Router decision and intermediate artifacts
400
+ route: str # text_only | uploads_only | mixed
401
+ parsed_docs: List[Dict[str, Any]] # [{id, name, text, meta}]
402
+ retrieved_chunks: List[str]
403
+ web_snippets: List[str]
404
+ citations: List[str]
405
+
406
+ # Schematic artifacts (NEW)
407
+ schematic_json: Dict[str, Any] # structured {components,nets,texts}
408
+ schematic_summary: str # short human summary from vision
409
+ part_ctx: str # concatenated datasheet snippets for detected parts
410
+ # Schematic control & ephemeral docs (NEW)
411
+ treat_images_as_schematics: bool # UI checkbox
412
+ has_schematic: bool # set True if this turn ingested schematic images
413
+ vision_docs: List[str] # fresh vision texts (summary + JSON) for this turn
414
+
415
+ # Demo/trace artifacts 👈 ADDED
416
+ trace: List[Dict[str, Any]]
417
+ # ---- LLM Inspector (NEW) ----
418
+ llm_events: List[Dict[str, Any]] # per-call telemetry
419
+
420
+ # User (NEW)
421
+ user: str
422
+ multi_queries: List[str]
423
+ retrieval_pool: List[List[Tuple[str,Dict[str,Any],float,str]]]
424
+ fused_hits: List[Tuple[str,Dict[str,Any],float,str]]
425
+ reranked_hits: List[Tuple[str,Dict[str,Any],float,str]]
426
+ mmr_hits: List[Tuple[str,Dict[str,Any],float,str]]
427
+
428
+ # Output and control flags
429
+ final_answer: str
430
+ need_clarification: bool
431
+ end_session: bool
432
+ report_path: str
433
+
434
+ # Termination tokens (explicit end required by your spec)
435
+ TERMINATE = {"ok", "exit", "finish"}
436
+
437
+ ### Helpers ###
438
+ def _tok_estimate(text: str) -> int:
439
+ """Fast, dependency-free token estimate (~4 chars/token)."""
440
+ if not text:
441
+ return 0
442
+ n = len(text)
443
+ return max(1, (n + 3) // 4)
444
+
445
+ def log_llm_event(state: State, *, node: str, model: str,
446
+ prompt_str: str, output_str: str,
447
+ usage: Optional[Dict[str, Any]], ms: float) -> None:
448
+ """Append a single LLM call record into state['llm_events'] for the inspector."""
449
+ ptok = int(usage.get("prompt_tokens", 0)) if usage else None
450
+ ctok = int(usage.get("completion_tokens", 0)) if usage else None
451
+ if ptok is None: ptok = _tok_estimate(prompt_str)
452
+ if ctok is None: ctok = _tok_estimate(output_str)
453
+
454
+ def _preview(s: str, k=300):
455
+ s = (s or "").strip().replace("\n", " ")
456
+ return (s[:k] + "…") if len(s) > k else s
457
+
458
+ (state.setdefault('llm_events', [])).append({
459
+ "node": node,
460
+ "model": model,
461
+ "latency_ms": int(ms),
462
+ "prompt_tokens": ptok,
463
+ "completion_tokens": ctok,
464
+ "total_tokens": ptok + ctok,
465
+ "prompt_preview": _preview(prompt_str),
466
+ "output_preview": _preview(output_str),
467
+ })
468
+
469
+
470
+
471
+ # =====================
472
+ # Graph Nodes (pure functions over State)
473
+ # =====================
474
+
475
+ def node_session(state: State) -> State:
476
+ """Check for explicit termination and initialize defaults if missing."""
477
+ t0 = time.perf_counter()
478
+ txt = (state.get('user_input') or '').strip().lower()
479
+ state['end_session'] = txt in TERMINATE
480
+ # clear previous turn’s trace
481
+ state['trace'] = []
482
+
483
+ # Ensure defaults present (useful if UI omits)
484
+ state['workspace'] = state.get('workspace') or DEFAULT_WS
485
+ state['provider'] = (state.get('provider') or PROVIDER).lower()
486
+ if state['provider'] != "groq":
487
+ state['provider'] = "groq"
488
+ state['model'] = state.get('model') or MODEL_G
489
+ state['ocr_backend'] = state.get('ocr_backend') or OCR_BACKEND
490
+ state['do_web_search'] = bool(state.get('do_web_search', tavily is not None))
491
+ state['schematic_json'] = state.get('schematic_json') or {}
492
+ state['schematic_summary'] = state.get('schematic_summary') or ""
493
+ state['part_ctx'] = state.get('part_ctx') or ""
494
+ state['treat_images_as_schematics'] = bool(state.get('treat_images_as_schematics'))
495
+
496
+ # --- Reset per-run ephemeral fields so old values don't echo into this turn ---
497
+ # These are populated by nodes during *this* run only.
498
+ state["parsed_docs"] = []
499
+ state["vision_docs"] = []
500
+ state["retrieval_events"] = []
501
+ state["llm_events"] = []
502
+ state["web_snippets"] = []
503
+ state["citations"] = []
504
+ state["retrieved_chunks"] = []
505
+ state["has_schematic"] = False
506
+ # Additional hygiene to avoid carry-over UI artifacts and stale schematic context
507
+ state.pop("report_path", None)
508
+ state["schematic_json"] = {} # clear any prior parsed schematic
509
+ state["schematic_summary"] = "" # clear prior summary
510
+ state["part_ctx"] = "" # clear prior datasheet snippets tied to a schematic
511
+ state.pop("route", None) # cosmetic: router will set this fresh each run
512
+
513
+ trace_push(state, "session", t0, f"end_session={state['end_session']}")
514
+ return state
515
+
516
+
517
+ def node_router(state: State) -> State:
518
+ """Classify input into text_only / uploads_only / mixed."""
519
+ t0 = time.perf_counter()
520
+ files = state.get('files') or []
521
+ has_pdf = any(f['name'].lower().endswith('.pdf') for f in files)
522
+ has_docx = any(f['name'].lower().endswith('.docx') for f in files)
523
+ has_img = any(f['name'].lower().endswith(('.png','.jpg','.jpeg','.bmp','.tif','.tiff')) for f in files)
524
+ kinds = sum([has_pdf, has_docx, has_img])
525
+ if kinds == 0:
526
+ state['route'] = 'text_only'
527
+ elif kinds == 1 and not (state.get('user_input') or '').strip():
528
+ state['route'] = 'uploads_only'
529
+ else:
530
+ state['route'] = 'mixed'
531
+ trace_push(state, "router", t0, f"route={state['route']}")
532
+ return state
533
+
534
+ def node_ingest(state: State) -> State:
535
+ """Parse and index uploaded files into the persistent workspace collection (Hybrid).
536
+ PDF → page-first, then sub-chunk long pages.
537
+ DOCX → heading-aware sections, then sub-chunk long sections.
538
+ Images in schematic mode → Vision (no OCR). Other images → OCR and chunk if needed.
539
+ """
540
+ t0 = time.perf_counter()
541
+ display_only: List[Dict[str, Any]] = [] # for "Indexed files" UI (no DB writes)
542
+ chunked_docs: List[Tuple[str, str, Dict[str, Any]]] = [] # to persist in Chroma
543
+ extra_docs: List[Tuple[str, str, Dict[str, Any]]] = [] # vision JSON/summary
544
+ ocr_backend = state.get('ocr_backend') or OCR_BACKEND
545
+ vision_texts: List[str] = [] # fresh vision docs for this turn
546
+ schematic_mode = bool(state.get('treat_images_as_schematics'))
547
+
548
+ progress = _get_progress_hook()
549
+ files_list = state.get('files') or []
550
+ total = max(1, len(files_list))
551
+
552
+ for i, f in enumerate(files_list):
553
+ name, data = f['name'], f['bytes']
554
+ if progress:
555
+ frac = i / total
556
+ progress(frac, desc=f"Indexing: {name}")
557
+ low = name.lower()
558
+ is_img = low.endswith(('.png', '.jpg', '.jpeg', '.bmp', '.tif', '.tiff'))
559
+
560
+ # --- PDF: page-first then sub-chunk ---
561
+ if low.endswith('.pdf'):
562
+ pages = parse_pdf_pages(data, ocr_backend=ocr_backend)
563
+ total_chars = sum(len(p or "") for p in pages)
564
+ display_only.append({'id': f"{name}-{i}", 'name': name, 'chars': total_chars})
565
+
566
+ for pidx, ptxt in enumerate(pages, start=1):
567
+ txt = (ptxt or "").strip()
568
+ if not txt:
569
+ continue
570
+ if len(txt) <= 2000:
571
+ body = f"[filename:{name}]\n[page:{pidx}]\n{txt}"
572
+ meta = {"filename": name, "doc_type": "pdf", "page": pidx}
573
+ chunked_docs.append((f"{name}-{i}-p{pidx}", body, meta))
574
+ else:
575
+ for j, ch in enumerate(chunk_text(txt, size=1200, overlap=180), start=1):
576
+ body = f"[filename:{name}]\n[page:{pidx}] [chunk:{j}]\n{ch}"
577
+ meta = {"filename": name, "doc_type": "pdf", "page": pidx, "chunk": j}
578
+ chunked_docs.append((f"{name}-{i}-p{pidx}-c{j}", body, meta))
579
+
580
+ # --- DOCX: heading-aware sections then sub-chunk ---
581
+ elif low.endswith('.docx'):
582
+ sections = parse_docx_sections(data)
583
+ total_chars = sum(len(t or "") for _sec, t in sections)
584
+ display_only.append({'id': f"{name}-{i}", 'name': name, 'chars': total_chars})
585
+
586
+ for sidx, (spath, stext) in enumerate(sections, start=1):
587
+ txt = (stext or "").strip()
588
+ if not txt:
589
+ continue
590
+ if len(txt) <= 1500:
591
+ body = f"[filename:{name}]\n[section:{spath}]\n{txt}"
592
+ meta = {"filename": name, "doc_type": "docx", "section_path": spath}
593
+ chunked_docs.append((f"{name}-{i}-s{sidx}", body, meta))
594
+ else:
595
+ for j, ch in enumerate(chunk_text(txt, size=1200, overlap=180), start=1):
596
+ body = f"[filename:{name}]\n[section:{spath}] [chunk:{j}]\n{ch}"
597
+ meta = {"filename": name, "doc_type": "docx", "section_path": spath, "chunk": j}
598
+ chunked_docs.append((f"{name}-{i}-s{sidx}-c{j}", body, meta))
599
+
600
+ # --- Images: schematic mode → Vision; else OCR+chunk if needed ---
601
+ elif is_img and schematic_mode:
602
+ # Vision path (no OCR record stored)
603
+ t_vision = time.perf_counter()
604
+ j, summary, raw, vusage = call_vision_extract(data, model=VISION_MODEL_T)
605
+ dt = (time.perf_counter() - t_vision) * 1000.0
606
+ _prompt = f"[vision] Extract JSON from schematic image: {name}"
607
+ _out = (summary or raw or "")[:1000]
608
+ log_llm_event(state, node="ingest", model=VISION_MODEL_T,
609
+ prompt_str=_prompt, output_str=_out, usage=vusage, ms=dt)
610
+
611
+ display_only.append({'id': f"{name}-{i}", 'name': name, 'chars': 0})
612
+ # (A) Flattened, embedding-friendly summary
613
+ if summary:
614
+ parts = []
615
+ try:
616
+ for c in (j or {}).get("components", []):
617
+ pn = c.get("part_number") or c.get("label") or ""
618
+ if pn:
619
+ parts.append(str(pn))
620
+ except Exception:
621
+ pass
622
+ flat = "[doc:vision_summary]\n[fname:%s]\ncomponents: %s\nsummary: %s" % (
623
+ name, (", ".join(parts) if parts else "(unknown)"), summary
624
+ )
625
+ extra_docs.append((
626
+ f"{name}-{i}-vision-summary",
627
+ flat,
628
+ {"filename": name, "doc_type": "vision_summary", "parts": ", ".join(parts) if parts else ""}
629
+ ))
630
+ vision_texts.append(flat)
631
+ # (B) Raw JSON as text
632
+ if j:
633
+ try:
634
+ jtxt = json.dumps(j, separators=(",", ":"))
635
+ parts = [(c.get("part_number") or c.get("label") or "") for c in j.get("components", [])]
636
+ except Exception:
637
+ jtxt, parts = "{}", []
638
+ jdoc = "[doc:vision_json]\n[fname:%s]\n%s" % (name, jtxt)
639
+ extra_docs.append((
640
+ f"{name}-{i}-vision-json",
641
+ jdoc,
642
+ {"filename": name, "doc_type": "vision_json", "parts": ", ".join(parts) if parts else "",
643
+ "nets_count": len((j or {}).get("nets", []))}
644
+ ))
645
+ vision_texts.append(jdoc)
646
+
647
+ else:
648
+ # generic image or other: OCR as before; chunk if long
649
+ text = parse_image(data, ocr_backend=ocr_backend) if is_img else ""
650
+ total_chars = len(text or "")
651
+ display_only.append({'id': f"{name}-{i}", 'name': name, 'chars': total_chars})
652
+ txt = (text or "").strip()
653
+ if not txt:
654
+ continue
655
+ if len(txt) <= 2000:
656
+ body = f"[filename:{name}]\n{text}"
657
+ meta = {"filename": name, "doc_type": "image_ocr" if is_img else "text"}
658
+ chunked_docs.append((f"{name}-{i}", body, meta))
659
+ else:
660
+ for j, ch in enumerate(chunk_text(txt, size=1200, overlap=180), start=1):
661
+ body = f"[filename:{name}] [chunk:{j}]\n{ch}"
662
+ meta = {"filename": name, "doc_type": "image_ocr" if is_img else "text", "chunk": j}
663
+ chunked_docs.append((f"{name}-{i}-c{j}", body, meta))
664
+
665
+ # Persist (DB writes)
666
+ col = get_collection(state['workspace'], state.get('user'))
667
+ if chunked_docs:
668
+ add_docs(col, chunked_docs)
669
+ if extra_docs:
670
+ add_docs(col, extra_docs)
671
+
672
+ # ✅ Final tick so the bar reaches 100%
673
+ if progress:
674
+ progress(1.0, desc="Indexing finished")
675
+
676
+ state['parsed_docs'] = display_only # ⬅️ UI only (for "Indexed files" summary)
677
+ state['vision_docs'] = vision_texts
678
+ state['has_schematic'] = state.get('has_schematic') or bool(vision_texts)
679
+ trace_push(state, "ingest", t0, f"indexed={len(display_only)} files, chunks={len(chunked_docs)} extra={len(extra_docs)}")
680
+ return state
681
+
682
+ def node_multiquery(state: State) -> State:
683
+ t0 = time.perf_counter()
684
+ q = (state.get('user_input') or '').strip()
685
+ provider = state['provider']; model = state['model']
686
+ prompt = f"""Rewrite the question into {MQ_N} diverse, *meaning-preserving* variants for better retrieval.
687
+ - Keep critical tokens (part numbers, pins, units) intact when present.
688
+ - Vary synonyms, scope (component vs system), and likely datasheet phrasing.
689
+ Return one variant per line, no numbering.
690
+
691
+ Q: {q}"""
692
+ msgs = [{"role":"system","content":"You generate retrieval variants only."},
693
+ {"role":"user","content":prompt}]
694
+ text, usage = chat_llm(msgs, provider=provider, model=model, max_tokens=1500)
695
+ variants = [v.strip(" •\t").strip() for v in (text or "").splitlines() if v.strip()]
696
+ # always include the original at the front (dedup later)
697
+ variants = [q] + [v for v in variants if v.lower() != q.lower()]
698
+ variants = variants[:MQ_N+1]
699
+ state['multi_queries'] = variants
700
+ log_llm_event(state, node="multiquery", model=model,
701
+ prompt_str="\n".join([m["content"] for m in msgs]),
702
+ output_str="\n".join(variants), usage=usage, ms=(time.perf_counter()-t0)*1000)
703
+ trace_push(state, "multiquery", t0, f"count={len(variants)}")
704
+ return state
705
+
706
+ def node_retrieve_pool(state: State) -> State:
707
+ t0 = time.perf_counter()
708
+ col = get_collection(state['workspace'], state.get('user'))
709
+ qs = state.get('multi_queries') or [(state.get('user_input') or '')]
710
+ pooled: List[List[Tuple[str,Dict[str,Any],float,str]]] = []
711
+ for qi in qs:
712
+ docs, metas, dists, ids = retrieve_info(col, qi, k=POOL_K_EACH)
713
+ pooled.append(list(zip(docs, metas, dists, ids)))
714
+ state['retrieval_pool'] = pooled
715
+ trace_push(state, "retrieve_pool", t0, f"lists={len(pooled)} x {POOL_K_EACH}")
716
+ return state
717
+
718
+ def node_rrf_fuse(state: State) -> State:
719
+ t0 = time.perf_counter()
720
+ pooled = state.get('retrieval_pool') or []
721
+ fused = rrf_fuse(pooled, k=FUSE_K, K=60)
722
+ state['fused_hits'] = fused
723
+ trace_push(state, "rrf_fuse", t0, f"kept={len(fused)}")
724
+ return state
725
+
726
+ def node_rerank(state: State) -> State:
727
+ t0 = time.perf_counter()
728
+ q = state.get('user_input') or ''
729
+ fused = state.get('fused_hits') or []
730
+ reranked = rerank_cross_encoder(q, fused, model_name=RERANK_MODEL)
731
+ state['reranked_hits'] = reranked
732
+ trace_push(state, "rerank", t0, f"top={min(10,len(reranked))}")
733
+ return state
734
+
735
+ def node_mmr(state: State) -> State:
736
+ t0 = time.perf_counter()
737
+ q = state.get('user_input') or ''
738
+ reranked = state.get('reranked_hits') or []
739
+ mmr = mmr_select(q, reranked, keep=MMR_KEEP, diversity_lambda=MMR_LAMBDA)
740
+ state['mmr_hits'] = mmr
741
+ trace_push(state, "mmr", t0, f"kept={len(mmr)}")
742
+ return state
743
+
744
+ def node_parent_promote(state: State) -> State:
745
+ t0 = time.perf_counter()
746
+ col = get_collection(state['workspace'], state.get('user'))
747
+ mmr = state.get('mmr_hits') or []
748
+
749
+ # Parent promotion (add limited siblings)
750
+ promoted = promote_parents(col, mmr, max_siblings_per_parent=PROMOTE_SIBLINGS)
751
+
752
+ # If this turn produced vision docs, put them first and ensure they’re in hits
753
+ vdocs = state.get('vision_docs') or []
754
+ hits_texts = [d for d, _m, _s, _id in promoted]
755
+ if vdocs:
756
+ hits_texts = list(vdocs) + hits_texts
757
+
758
+ # Build retrieval_events (table)
759
+ events: List[Dict[str,Any]] = []
760
+ rank = 1
761
+ if vdocs:
762
+ for vd in vdocs:
763
+ events.append({"rank": 0, "score": None, "filename": "(vision)", "doc_type": "vision",
764
+ "page": None, "section": None, "preview": vd.replace("\n"," ")[:220]})
765
+ for (doc, meta, score, _id) in promoted:
766
+ m = meta or {}
767
+ filename = os.path.basename(str(m.get("filename") or m.get("source") or m.get("name") or "")) or "(unknown)"
768
+ kind = m.get("doc_type") or ("pdf" if filename.lower().endswith(".pdf") else ("docx" if filename.lower().endswith(".docx") else "text"))
769
+ page = m.get("page"); section = m.get("section_path")
770
+ preview = (doc or "").replace("\n"," ")[:220]
771
+ events.append({"rank": rank, "score": (None if score is None else round(float(score),3)),
772
+ "filename": filename, "doc_type": kind, "page": page, "section": section, "preview": preview})
773
+ rank += 1
774
+
775
+ state['retrieval_events'] = events
776
+ state['retrieved_chunks'] = hits_texts
777
+ trace_push(state, "parent_promote", t0, f"hits={len(hits_texts)}")
778
+ return state
779
+
780
+ # def node_retrieve(state: State) -> State:
781
+ # """Retrieve top-k chunks from the workspace collection given the current question."""
782
+ # t0 = time.perf_counter()
783
+ # q = state.get('user_input') or ''
784
+ # col = get_collection(state['workspace'], state.get('user'))
785
+
786
+ # docs, metas, dists, ids = retrieve_info(col, q, k=8) # a few more since chunks are smaller with Hybrid
787
+ # events: List[Dict[str, Any]] = []
788
+ # for rank, (doc, meta, dist, _id) in enumerate(zip(docs, metas, dists, ids), 1):
789
+ # meta = meta or {}
790
+ # filename = os.path.basename(str(meta.get("filename") or meta.get("source") or meta.get("name") or "")) or "(unknown)"
791
+ # kind = meta.get("doc_type") or ("pdf" if filename.lower().endswith(".pdf") else ("docx" if filename.lower().endswith(".docx") else "text"))
792
+ # page = meta.get("page")
793
+ # section = meta.get("section_path")
794
+ # preview = (doc or "").replace("\n", " ")[:220]
795
+ # # Chroma gives a distance; convert to a similarity-ish score (optional)
796
+ # score = None
797
+ # try:
798
+ # score = 1.0 - float(dist) if dist is not None else None
799
+ # except Exception:
800
+ # score = None
801
+ # events.append({
802
+ # "rank": rank,
803
+ # "score": (None if score is None else round(score, 3)),
804
+ # "filename": filename,
805
+ # "doc_type": kind,
806
+ # "page": page,
807
+ # "section": section,
808
+ # "preview": preview,
809
+ # })
810
+
811
+ # # If this turn produced vision docs, show them first and ensure they are in hits
812
+ # vdocs = state.get('vision_docs') or []
813
+ # if vdocs:
814
+ # v_events = []
815
+ # for vd in vdocs:
816
+ # v_events.append({
817
+ # "rank": 0,
818
+ # "score": None,
819
+ # "filename": "(vision)",
820
+ # "doc_type": "vision",
821
+ # "page": None,
822
+ # "section": None,
823
+ # "preview": vd.replace("\n", " ")[:220],
824
+ # })
825
+ # state['retrieval_events'] = v_events + events
826
+ # hits = list(vdocs) + list(docs or [])
827
+ # else:
828
+ # state['retrieval_events'] = events
829
+ # hits = docs or []
830
+
831
+ # state['retrieved_chunks'] = hits
832
+ # trace_push(state, "retrieve", t0, f"hits={len(hits or [])}")
833
+ # return state
834
+
835
+
836
+ def node_web(state: State) -> State:
837
+ """Optional web search via Tavily; stores snippets and raw citations."""
838
+ t0 = time.perf_counter()
839
+ state['web_snippets'] = []
840
+ state['citations'] = []
841
+ if not (state.get('do_web_search') and tavily):
842
+ trace_push(state, "web", t0, "skipped")
843
+ return state
844
+ q = state.get('user_input') or ''
845
+ if not q.strip():
846
+ trace_push(state, "web", t0, "skipped")
847
+ return state
848
+ try:
849
+ res = tavily.search(q, max_results=5)
850
+ snippets: List[str] = []
851
+ cites: List[str] = []
852
+ for item in res.get('results', [])[:5]:
853
+ title = item.get('title') or 'source'
854
+ url = item.get('url') or ''
855
+ content = (item.get('content') or '')[:900]
856
+ snippets.append(f"[{title}] {content}")
857
+ if url:
858
+ cites.append(url)
859
+ state['web_snippets'] = snippets
860
+ state['citations'] = cites
861
+ trace_push(state, "web", t0, f"sources={len(cites)}")
862
+ except Exception as e:
863
+ state['web_snippets'] = [f"[web search error] {e}"]
864
+ trace_push(state, "web", t0, "error")
865
+ return state
866
+
867
+ def parts_expand(state: State) -> State:
868
+ """From the retrieved vision JSON/summary, extract part numbers and fetch datasheet context."""
869
+ t0 = time.perf_counter()
870
+ chunks = state.get('retrieved_chunks') or []
871
+ json_text = ""
872
+ summary_text = ""
873
+ # pick the first matching blocks
874
+ for c in chunks:
875
+ if "[doc:vision_json]" in c:
876
+ json_text = c
877
+ break
878
+ for c in chunks:
879
+ if "[doc:vision_summary]" in c:
880
+ summary_text = c
881
+ break
882
+
883
+ # parse JSON (strip tags + find {...})
884
+ j = {}
885
+ try:
886
+ body = json_text.split("\n", 2)[-1] if json_text else ""
887
+ start = body.find("{"); end = body.rfind("}")
888
+ if start != -1 and end != -1 and end > start:
889
+ j = json.loads(body[start:end+1])
890
+ except Exception:
891
+ j = {}
892
+
893
+ # extract unique part numbers (fallback to label if pn missing)
894
+ pns: List[str] = []
895
+ for c in j.get("components", []):
896
+ pn = c.get("part_number") or c.get("label") or ""
897
+ pn = str(pn).strip()
898
+ if pn and pn not in pns:
899
+ pns.append(pn)
900
+
901
+ # retrieve a few chunks per PN from the local collection
902
+ col = get_collection(state['workspace'], state.get('user'))
903
+ part_ctx_segments: List[str] = []
904
+ for pn in pns:
905
+ hits = retrieve(col, pn, k=3)
906
+ if hits:
907
+ part_ctx_segments.append(f"[{pn}] " + "\n".join(hits))
908
+
909
+ state['schematic_json'] = j
910
+ state['schematic_summary'] = summary_text
911
+ state['part_ctx'] = "\n\n".join(part_ctx_segments)
912
+ trace_push(state, "parts_expand", t0, f"parts={len(pns)} ctx_segs={len(part_ctx_segments)}")
913
+ return state
914
+
915
+ def schematic_answer(state: State) -> State:
916
+ """Use the structured JSON + part_ctx to produce a precise circuit explanation."""
917
+ t0 = time.perf_counter()
918
+ provider = state['provider']
919
+ model = state['model']
920
+
921
+ j = state.get('schematic_json') or {}
922
+ part_ctx = state.get('part_ctx') or ""
923
+ summary = state.get('schematic_summary') or ""
924
+ user_q = state.get('user_input') or ""
925
+
926
+ # keep it compact but explicit
927
+ sys = (
928
+ f"You are a senior hardware design assistant. Default language: {DEFAULT_LANGUAGE}. "
929
+ "You are given structured {components, nets} extracted from a schematic, plus datasheet snippets. "
930
+ "Respond with:\n"
931
+ "1) Chips list (ref, part, role)\n"
932
+ "2) Key connections (Net: ref.pin → ref.pin)\n"
933
+ "3) Functional description (concise)\n"
934
+ "4) Any assumptions/uncertainties\n"
935
+ "Quote short phrases from provided context when you rely on them."
936
+ )
937
+
938
+ j_txt = json.dumps(j, indent=2) if j else "{}"
939
+ ctx = []
940
+ if summary: ctx.append(summary)
941
+ if part_ctx: ctx.append(part_ctx)
942
+ rag_ctx = "\n\n".join(ctx)
943
+
944
+ messages = [
945
+ {"role": "system", "content": sys},
946
+ {"role": "user", "content": f"Question: {user_q}\n\nStructured JSON:\n{j_txt}\n\nContext:\n{rag_ctx}\n\nBe specific; use refdes and pin names. If something is unknown, say so."},
947
+ ]
948
+
949
+ # --- Citations policy + list (reuse web citations if any) ---
950
+ cits = state.get("citations") or []
951
+ allowed_urls = [u for u in cits if isinstance(u, str) and u.startswith("http")]
952
+ cit_lines = [f"[{i+1}] {u}" for i, u in enumerate(allowed_urls)]
953
+ citations_block = "\n".join(cit_lines)
954
+
955
+ policy = (
956
+ "CITATIONS POLICY:\n"
957
+ "- Do NOT output a standalone 'Sources' or 'References' section.\n"
958
+ "- If you cite, use bracketed indices like [1], [2] referring ONLY to the CITATIONS list provided.\n"
959
+ "- If no CITATIONS are provided, do not invent links.\n"
960
+ f"CITATIONS:\n{citations_block if cit_lines else '(none)'}"
961
+ )
962
+ # Prepend policy to steer the model
963
+ messages.insert(0, {"role": "system", "content": policy})
964
+
965
+ reply,usage = chat_llm(messages, provider=provider, model=model, max_tokens=2000, temperature=0.2)
966
+ # scrub bogus 'Sources' and any stray URLs not in our allowed list
967
+ reply = sanitize_answer(reply, allowed_urls) # ✅ removes any “Sources:” footer / stray URLs
968
+ state['final_answer'] = reply
969
+ dt = (time.perf_counter() - t0) * 1000.0
970
+ # compact prompt string for inspector (text parts only)
971
+ _prompt_str = "\n".join([f"{m['role']}: {m['content']}" for m in messages])
972
+ log_llm_event(state, node="schematic_answer", model=model,
973
+ prompt_str=_prompt_str, output_str=reply, usage=usage, ms=dt)
974
+
975
+ pt = usage.get("prompt_tokens","~"); ct = usage.get("completion_tokens","~")
976
+ trace_push(state, "schematic_answer", t0, f"model={model} in={pt} out={ct}")
977
+ return state
978
+
979
+
980
+ def node_answer(state: State) -> State:
981
+ """Compose system+user messages with RAG/Web context and call the model."""
982
+ t0 = time.perf_counter()
983
+ provider = state['provider']
984
+ model = state['model']
985
+
986
+ rag_ctx = "\n\n".join(state.get('retrieved_chunks') or [])
987
+ web_ctx = "\n\n".join(state.get('web_snippets') or [])
988
+
989
+ sys = (
990
+ f"You are a senior hardware design assistant. Default language: {DEFAULT_LANGUAGE}. "
991
+ "Be precise; show formulas/units; cite short snippets (in quotes) when you rely on context. "
992
+ "If uncertain, ask for page/figure/pin/net names you need."
993
+ )
994
+
995
+ user_q = state.get('user_input') or ''
996
+ if not user_q.strip():
997
+ state['final_answer'] = (
998
+ "I indexed your files into the workspace. Ask a question (e.g., 'Compute bias current for Q1', 'Check the op-amp stability with R_L=…')."
999
+ )
1000
+ trace_push(state, "answer", t0, f"model={model} (no question)")
1001
+ return state
1002
+
1003
+ ctx = f"""RAG context (uploaded/private):
1004
+ {rag_ctx or "(none)"}
1005
+
1006
+ Web context (Tavily):
1007
+ {web_ctx or "(none)"}"""
1008
+
1009
+ messages = [
1010
+ {"role": "system", "content": sys},
1011
+ {"role": "user", "content": f"""Question:
1012
+ {user_q}
1013
+
1014
+ Use context if helpful; do not hallucinate.
1015
+
1016
+ {ctx}"""},
1017
+ ]
1018
+
1019
+ # --- Citations policy + list (only if web citations exist) ---
1020
+ cits = state.get("citations") or []
1021
+ allowed_urls = [u for u in cits if isinstance(u, str) and u.startswith("http")]
1022
+ cit_lines = [f"[{i+1}] {u}" for i, u in enumerate(allowed_urls)]
1023
+ citations_block = "\n".join(cit_lines)
1024
+
1025
+ policy = (
1026
+ "CITATIONS POLICY:\n"
1027
+ "- Do NOT output a standalone 'Sources' or 'References' section.\n"
1028
+ "- If you cite, use bracketed indices like [1], [2] referring ONLY to the CITATIONS list provided.\n"
1029
+ "- If no CITATIONS are provided, do not invent links.\n"
1030
+ f"CITATIONS:\n{citations_block if cit_lines else '(none)'}"
1031
+ )
1032
+
1033
+ # Prepend the policy as a system message to discipline output
1034
+ messages.insert(0, {"role": "system", "content": policy})
1035
+
1036
+
1037
+ reply, usage = chat_llm(messages, provider=provider, model=model)
1038
+ reply = sanitize_answer(reply, allowed_urls)
1039
+
1040
+ state['final_answer'] = reply
1041
+ dt = (time.perf_counter() - t0) * 1000.0
1042
+ _prompt_str = "\n".join([f"{m['role']}: {m['content']}" for m in messages])
1043
+ log_llm_event(state, node="answer", model=model,
1044
+ prompt_str=_prompt_str, output_str=reply, usage=usage, ms=dt)
1045
+
1046
+ pt = usage.get("prompt_tokens","~"); ct = usage.get("completion_tokens","~")
1047
+ trace_push(state, "answer", t0, f"model={model} in={pt} out={ct}")
1048
+
1049
+ return state
1050
+
1051
+ def node_verify(state: State) -> State:
1052
+ """Light heuristic verification → if weak context/answer, request clarifications."""
1053
+ t0 = time.perf_counter()
1054
+ ans = state.get('final_answer') or ''
1055
+ chunks = state.get('retrieved_chunks') or []
1056
+ web = state.get('web_snippets') or []
1057
+ state['need_clarification'] = (len(chunks) < 1 and len(web) < 1) or (len(ans) < 120)
1058
+ trace_push(state, "verify", t0, "clarify" if state['need_clarification'] else "ok")
1059
+ return state
1060
+
1061
+
1062
+ def inventory_docs(state: State) -> State:
1063
+ """List previously uploaded PDFs/DOCX in the current project."""
1064
+ t0 = time.perf_counter()
1065
+ col = get_collection(state['workspace'], state.get('user'))
1066
+ res = col.get(include=["metadatas"])
1067
+ seen = set()
1068
+ for m in (res.get("metadatas") or []):
1069
+ m = m or {}
1070
+ if (m.get("doc_type") in ("pdf", "docx")):
1071
+ fn = m.get("filename") or m.get("source") or m.get("name") or ""
1072
+ fn = os.path.basename(str(fn))
1073
+ if fn:
1074
+ seen.add(fn)
1075
+ files = sorted(seen, key=str.lower)
1076
+ n = len(files)
1077
+ bullets = "\n".join(f"- {f}" for f in files) or "(none found)"
1078
+ state["final_answer"] = f"You have **{n}** document(s) (PDF/DOCX) in this project:\n\n{bullets}"
1079
+ trace_push(state, "inventory_docs", t0, f"count={n}")
1080
+ return state
1081
+
1082
+ def inventory_schematics(state: State) -> State:
1083
+ """List previously uploaded schematics recognized via Vision (by filename)."""
1084
+ t0 = time.perf_counter()
1085
+ col = get_collection(state['workspace'], state.get('user'))
1086
+ res = col.get(include=["metadatas"])
1087
+ seen = set()
1088
+ for m in (res.get("metadatas") or []):
1089
+ m = m or {}
1090
+ # Schematic artifacts are stored as 'vision_summary' and 'vision_json'
1091
+ if m.get("doc_type") in ("vision_summary", "vision_json"):
1092
+ fn = m.get("filename") or m.get("source") or m.get("name") or ""
1093
+ fn = os.path.basename(str(fn))
1094
+ if fn:
1095
+ seen.add(fn)
1096
+ files = sorted(seen, key=str.lower)
1097
+ n = len(files)
1098
+ bullets = "\n".join(f"- {f}" for f in files) or "(none found)"
1099
+ state["final_answer"] = f"You have **{n}** schematic image(s) in this project:\n\n{bullets}"
1100
+ trace_push(state, "inventory_schematics", t0, f"count={n}")
1101
+ return state
1102
+
1103
+ def node_clarify(state: State) -> State:
1104
+ """Ask targeted follow-ups to resolve ambiguity (pins, refs, conditions)."""
1105
+ t0 = time.perf_counter()
1106
+ provider = state['provider']
1107
+ model = state['model']
1108
+ user_q = state.get('user_input') or ''
1109
+
1110
+ messages = [
1111
+ {"role": "system", "content": "Ask up to 3 precise clarification questions to answer a hardware design query."},
1112
+ {"role": "user", "content": f"""We need more details to answer this question accurately:
1113
+ {user_q}
1114
+
1115
+ Focus your questions on: component part numbers, voltage/current conditions, pin/net names, figure/page numbers, test points."""},
1116
+ ]
1117
+
1118
+ reply, usage = chat_llm(messages, provider=provider, model=model, max_tokens=5000)
1119
+ state['final_answer'] = reply
1120
+
1121
+ dt = (time.perf_counter() - t0) * 1000.0
1122
+ _prompt_str = "\n".join([f"{m['role']}: {m['content']}" for m in messages])
1123
+ log_llm_event(state, node="clarify", model=model,
1124
+ prompt_str=_prompt_str, output_str=reply, usage=usage, ms=dt)
1125
+
1126
+ trace_push(state, "clarify", t0, "follow-ups asked")
1127
+ return state
1128
+
1129
+
1130
+ def node_report(state: State) -> State:
1131
+ """Generate a Markdown session summary and save it to a temp file for download."""
1132
+ t0 = time.perf_counter()
1133
+ provider = state['provider']
1134
+ model = state['model']
1135
+ hist = state.get('history', [])
1136
+ last_turns = "\n".join([f"{m['role']}: {m['content']}" for m in hist[-12:]])
1137
+
1138
+ messages = [
1139
+ {"role": "system", "content": "Summarize the session into Markdown with: bullet points, equations if present, cited snippets, and TODOs."},
1140
+ {"role": "user", "content": f"""Summarize this hardware design assistant session.
1141
+
1142
+ {last_turns}"""}
1143
+ ]
1144
+ summary_md, usage = chat_llm(messages, provider=provider, model=model, max_tokens=5000)
1145
+
1146
+ path = os.path.join(tempfile.gettempdir(), f"hwqa_summary_{state.get('thread_id','x')}.md")
1147
+ with open(path, 'w', encoding='utf-8') as f:
1148
+ f.write(summary_md)
1149
+
1150
+ state['final_answer'] = "Session ended. A Markdown report has been generated (see download below).\n\n" + summary_md
1151
+ state['report_path'] = path
1152
+
1153
+ dt = (time.perf_counter() - t0) * 1000.0
1154
+ _prompt_str = "\n".join([f"{m['role']}: {m['content']}" for m in messages])
1155
+ log_llm_event(state, node="report", model=model,
1156
+ prompt_str=_prompt_str, output_str=summary_md, usage=usage, ms=dt)
1157
+
1158
+ trace_push(state, "report", t0, "summary generated")
1159
+ return state
1160
+
1161
+
1162
+ # =====================
1163
+ # Graph wiring (this is the showcase part)
1164
+ # =====================
1165
+
1166
+ def cond_end(state: State) -> str:
1167
+ return 'end' if state.get('end_session') else 'continue'
1168
+
1169
+
1170
+ def cond_route(state: State) -> str:
1171
+ return state.get('route','text_only')
1172
+
1173
+ # --- Simple intent detectors for inventory commands ---
1174
+ def _want_inventory_docs(q: str) -> bool:
1175
+ return (q or "").strip().lower() == "list docs"
1176
+
1177
+ def _want_inventory_schematics(q: str) -> bool:
1178
+ return (q or "").strip().lower() == "list schematics"
1179
+
1180
+ def cond_after_retrieve(state: State) -> str:
1181
+ """Decide next step after retrieval, returning one of:
1182
+ 'inv_docs' | 'inv_schematics' | 'schematic' | 'with_web' | 'direct'
1183
+ """
1184
+ hits = state.get("retrieved_chunks") or []
1185
+ q = state.get("user_input") or ""
1186
+
1187
+ # 🔎 Inventory intents (fast-exit)
1188
+ if _want_inventory_schematics(q):
1189
+ return "inv_schematics"
1190
+ if _want_inventory_docs(q):
1191
+ return "inv_docs"
1192
+
1193
+ # Schematic path wins if we ingested/recognized a schematic this turn
1194
+ try:
1195
+ if state.get("has_schematic"):
1196
+ return "schematic"
1197
+ except Exception:
1198
+ pass
1199
+
1200
+ # ✅ New rule: when the toggle is ON, always visit the web node next.
1201
+ if bool(state.get("do_web_search")):
1202
+ return "with_web"
1203
+
1204
+ # Otherwise keep the existing local-only heuristic
1205
+ if len(hits) >= 2:
1206
+ return "direct"
1207
+
1208
+ # Otherwise, still go direct (the answer node may ask for clarification later)
1209
+ return "direct"
1210
+
1211
+
1212
+ def cond_verify(state: State) -> str:
1213
+ return 'clarify' if state.get('need_clarification') else 'ok'
1214
+
1215
+
1216
+ def build_graph():
1217
+ g = StateGraph(State)
1218
+
1219
+ # Register nodes
1220
+ g.add_node('session', node_session)
1221
+ g.add_node('router', node_router)
1222
+ g.add_node('ingest', node_ingest)
1223
+ g.add_node('multiquery', node_multiquery)
1224
+ g.add_node('retrieve_pool', node_retrieve_pool)
1225
+ g.add_node('rrf_fuse', node_rrf_fuse)
1226
+ g.add_node('rerank', node_rerank)
1227
+ g.add_node('mmr', node_mmr)
1228
+ g.add_node('parent_promote', node_parent_promote)
1229
+
1230
+ g.add_node('web', node_web)
1231
+ g.add_node('answer', node_answer)
1232
+ g.add_node('verify', node_verify)
1233
+ g.add_node('clarify', node_clarify)
1234
+ g.add_node('report', node_report)
1235
+ g.add_node('parts_expand', parts_expand)
1236
+ g.add_node('schematic_answer', schematic_answer)
1237
+ g.add_node('inventory_docs', inventory_docs)
1238
+ g.add_node('inventory_schematics', inventory_schematics)
1239
+
1240
+
1241
+
1242
+ # Entry point → either end (report) or continue to router
1243
+ g.set_entry_point('session')
1244
+ g.add_conditional_edges('session', cond_end, {
1245
+ 'end': 'report',
1246
+ 'continue': 'router',
1247
+ })
1248
+
1249
+ # Router → either straight to retrieve (text_only) or ingest first (uploads)
1250
+ g.add_conditional_edges('router', cond_route, {
1251
+ 'text_only': 'multiquery',
1252
+ 'uploads_only': 'ingest',
1253
+ 'mixed': 'ingest',
1254
+ })
1255
+
1256
+ # After ingest, go multiquery; then maybe web; then answer; then verify
1257
+ g.add_edge('ingest', 'multiquery')
1258
+ # Retrieval chain
1259
+ g.add_edge('multiquery', 'retrieve_pool')
1260
+ g.add_edge('retrieve_pool', 'rrf_fuse')
1261
+ g.add_edge('rrf_fuse', 'rerank')
1262
+ g.add_edge('rerank', 'mmr')
1263
+ g.add_edge('mmr', 'parent_promote')
1264
+
1265
+ g.add_conditional_edges('parent_promote', cond_after_retrieve, {
1266
+ 'inv_docs': 'inventory_docs',
1267
+ 'inv_schematics': 'inventory_schematics',
1268
+ 'schematic': 'parts_expand',
1269
+ 'with_web': 'web',
1270
+ 'direct': 'answer',
1271
+ })
1272
+
1273
+ g.add_edge('web', 'answer')
1274
+ g.add_edge('parts_expand', 'schematic_answer')
1275
+ g.add_edge('schematic_answer', 'verify')
1276
+ g.add_edge('inventory_docs', 'verify')
1277
+ g.add_edge('inventory_schematics', 'verify')
1278
+ g.add_edge('answer', 'verify')
1279
+
1280
+ # Verify can ask for clarifications or finish the turn
1281
+ g.add_conditional_edges('verify', cond_verify, {
1282
+ 'clarify': 'clarify',
1283
+ 'ok': END,
1284
+ })
1285
+ g.add_edge('clarify', END)
1286
+ g.set_finish_point('clarify')
1287
+
1288
+ # MemorySaver shows you know checkpointing; for persistence across turns in a server, swap for a DB-backed store
1289
+ mem = MemorySaver()
1290
+ return g.compile(checkpointer=mem)
1291
+
1292
+ # Build the compiled app once (import‑time)
1293
+ APP = build_graph()
1294
+
1295
+ # =====================
1296
+ # Gradio UI — simple but expressive for demos
1297
+ # =====================
1298
+
1299
+ def to_pairs(history: List[Dict[str, str]]):
1300
+ """Convert role-wise history into Chatbot (user, assistant) pairs."""
1301
+ pairs: List[Tuple[str, str]] = []
1302
+ u: Optional[str] = None
1303
+ for m in history:
1304
+ if m['role'] == 'user':
1305
+ u = m['content']
1306
+ elif m['role'] == 'assistant' and u is not None:
1307
+ pairs.append((u, m['content']))
1308
+ u = None
1309
+ return pairs
1310
+
1311
+ def run_once(request: gr.Request,
1312
+ thread_id: str,
1313
+ chat_pairs: List[Tuple[str, str]],
1314
+ msg: str,
1315
+ files: List[gr.File],
1316
+ project: str,
1317
+ provider: str,
1318
+ model: str,
1319
+ ocr_backend: str,
1320
+ do_web_search: bool,
1321
+ treat_images_as_schematics: bool,
1322
+ progress=None):
1323
+ """Execute one graph turn; user is derived from the JWT cookie; workspace = selected project."""
1324
+ # Who is logged in?
1325
+ user_email = get_logged_in_email_from_request(request) or "anonymous"
1326
+
1327
+ # Rehydrate conversation history into role messages
1328
+ history: List[Dict[str, str]] = []
1329
+ for u_msg, a_msg in (chat_pairs or []):
1330
+ history += [
1331
+ {"role": "user", "content": u_msg},
1332
+ {"role": "assistant", "content": a_msg},
1333
+ ]
1334
+
1335
+ # Read uploaded files as bytes now (Gradio gives temp filepaths)
1336
+ uploads: List[Dict[str, Any]] = []
1337
+ for f in files or []:
1338
+ try:
1339
+ with open(f.name, 'rb') as fp:
1340
+ uploads.append({"name": os.path.basename(f.name), "bytes": fp.read()})
1341
+ except Exception:
1342
+ pass
1343
+
1344
+ # Initial state for this turn
1345
+ init: State = {
1346
+ 'thread_id': thread_id,
1347
+ 'history': history,
1348
+ 'user_input': msg or '',
1349
+ 'files': uploads,
1350
+ 'workspace': project or DEFAULT_WS,
1351
+ 'user': user_email,
1352
+ 'provider': (provider or PROVIDER).lower(),
1353
+ 'model': model or MODEL_G,
1354
+ 'ocr_backend': ocr_backend or OCR_BACKEND,
1355
+ 'do_web_search': bool(do_web_search),
1356
+ 'treat_images_as_schematics': bool(treat_images_as_schematics),
1357
+ 'trace': [] # reset per turn so MemorySaver doesn’t carry old steps
1358
+ }
1359
+
1360
+ # Run the graph (one pass)
1361
+ _set_progress_hook(progress) # make the hook visible to nodes, but not in State
1362
+ out: State = APP.invoke(init, config={"configurable": {"thread_id": thread_id}})
1363
+
1364
+ # Prepare reply content
1365
+ reply = out.get('final_answer') or '(no reply)'
1366
+ history.append({"role": "user", "content": msg})
1367
+
1368
+ # If we ingested files this turn, prepend a tiny indexing summary for transparency
1369
+ parsed = out.get('parsed_docs') or []
1370
+ prefix = ""
1371
+ if parsed:
1372
+ prefix = "Indexed files:\n" + "\n".join([f"• {d['name']} ({d.get('chars', len(d.get('text','')))} chars)" for d in parsed]) + "\n\n"
1373
+
1374
+
1375
+ history.append({"role": "assistant", "content": prefix + reply})
1376
+
1377
+ # Build artifacts for the Trace tab
1378
+ trace_md, _trace_json, dag_img = render_trace_artifacts(out)
1379
+
1380
+ # Expose a Markdown report file if the session ended this turn
1381
+ report_path = out.get('report_path', None)
1382
+ return to_pairs(history), (report_path if report_path else None), trace_md, dag_img
1383
+
1384
+ def build_gradio_blocks(auth_secret: Optional[str] = None):
1385
+ # 👇 Force the same secret that FastAPI used to sign the JWT
1386
+ global AUTH_SECRET
1387
+ if auth_secret:
1388
+ AUTH_SECRET = auth_secret
1389
+ print("[debug] Gradio AUTH_SECRET prefix:", AUTH_SECRET[:8])
1390
+
1391
+ with gr.Blocks(title="Hardware QA Assistant — Advanced LangGraph", css=UPLOADER_CSS, theme="freddyaboulton/dracula_revamped") as demo:
1392
+ gr.Markdown("# Hardware QA Assistant — Advanced LangGraph 🔧Developer: Antonios Karvelas")
1393
+ gr.Markdown("Upload datasheets/schematics; ask questions. Type **OK / exit / finish** to end and get a Markdown report.")
1394
+
1395
+ thread = gr.State(value=os.urandom(4).hex())
1396
+
1397
+ with gr.Tabs():
1398
+ with gr.Tab("Chat"):
1399
+ # --- User bar ---
1400
+ user_md = gr.Markdown("User: _(not logged in)_", elem_id="user_label")
1401
+ logout_html = gr.HTML('<div style="text-align:right;"><a href="/logout">Logout</a></div>')
1402
+
1403
+ # --- Workspace Manager (per-user) ---
1404
+ with gr.Accordion("Workspace Manager (per-user projects)", open=False):
1405
+ with gr.Row():
1406
+ project_dd = gr.Dropdown(choices=[], label="Project", allow_custom_value=True)
1407
+ refresh_btn = gr.Button("Refresh list")
1408
+ new_name = gr.Textbox(label="New project name")
1409
+ new_btn = gr.Button("Create")
1410
+ del_btn = gr.Button("Delete selected")
1411
+
1412
+ # --- Model / OCR / Web ---
1413
+ with gr.Row():
1414
+ provider = gr.Dropdown(choices=["groq"], value=PROVIDER, label="LLM Provider")
1415
+ model = gr.Textbox(value=MODEL_G, label="Model name") # Groq text model
1416
+ def _on_provider_change(prov: str):
1417
+ return gr.update(value=MODEL_G)
1418
+ provider.change(_on_provider_change, inputs=provider, outputs=model)
1419
+ with gr.Row():
1420
+ ocr_backend = gr.Dropdown(choices=["paddle","tesseract","auto"], value=OCR_BACKEND, label="OCR backend")
1421
+ do_web_search = gr.Checkbox(value=True if tavily else False, label="Use Tavily web search if needed")
1422
+
1423
+ chat = gr.Chatbot(height=560, type="tuples", elem_id="chatui")
1424
+ # Uploader + Vision checkbox side-by-side (compact)
1425
+
1426
+ msg = gr.Textbox(
1427
+ label="Your message",
1428
+ placeholder="Ask about a circuit/component… e.g., 'Is the LDO stable with 10µF and ESR=50mΩ?' | Tip: type 'List docs' (PDF/DOCX) or 'List schematics' (images).",
1429
+ info="Tip: 'List docs' → list uploaded documents (PDF/DOCX). 'List schematics' → list uploaded schematic images."
1430
+ )
1431
+ send = gr.Button("Send")
1432
+ with gr.Row(equal_height=True):
1433
+ with gr.Column(scale=3, min_width=160):
1434
+ files = gr.File(
1435
+ label="Upload PDFs / DOCX / images",
1436
+ file_count="multiple",
1437
+ container=False, # trims extra vertical padding
1438
+ elem_id="uploader" # CSS hook
1439
+ )
1440
+ with gr.Column(scale=1, min_width=120):
1441
+ treat_as_schematic = gr.Checkbox(
1442
+ value=False,
1443
+ label="Treat images as schematics (use Vision)",
1444
+ container=False, # <-- trims padding
1445
+ elem_id="schem_tick" # <-- CSS hook below
1446
+ )
1447
+
1448
+
1449
+
1450
+ summary = gr.File(label="Session summary (Markdown)", visible=False)
1451
+
1452
+ with gr.Tab("Trace"):
1453
+ trace_md = gr.Markdown(value="(Run a query to see the last execution trace here.)", elem_id="trace_md")
1454
+ dag_img = gr.Image(label="Graph (executed path highlighted)", type="pil")
1455
+
1456
+ with gr.Tab("Manage PDF files"):
1457
+ gr.Markdown("### Delete PDFs from the current Project\n"
1458
+ "Pick the files you want to remove from retrieval (Chroma).")
1459
+ with gr.Row():
1460
+ gr.Markdown("Current **Project** selection above will be used.")
1461
+ refresh_files_btn = gr.Button("Refresh file list")
1462
+ files_ck = gr.CheckboxGroup(label="Files in project", choices=[], interactive=True)
1463
+ with gr.Row():
1464
+ delete_files_btn = gr.Button("Delete selected", variant="stop")
1465
+ manage_status = gr.Markdown()
1466
+
1467
+ # Wire handlers
1468
+ def _ui_refresh_files(request: gr.Request, ws):
1469
+ email = get_logged_in_email_from_request(request)
1470
+ choices = list_workspace_files_by_filename(ws, email)
1471
+ return gr.update(choices=choices, value=[])
1472
+
1473
+ def _ui_delete_files(request: gr.Request, ws, picked):
1474
+ email = get_logged_in_email_from_request(request)
1475
+ msg = delete_files_by_filename(ws, email, picked or [])
1476
+ return msg
1477
+
1478
+ refresh_files_btn.click(_ui_refresh_files, inputs=project_dd, outputs=files_ck)
1479
+ delete_files_btn.click(_ui_delete_files, inputs=[project_dd, files_ck], outputs=manage_status) \
1480
+ .then(_ui_refresh_files, inputs=project_dd, outputs=files_ck)
1481
+
1482
+
1483
+ with gr.Tab("Report"):
1484
+ gr.Markdown("### View generated report(s)\n"
1485
+ "Load the latest or choose a specific `hwqa_summary_*.md` and render it below.")
1486
+ with gr.Row():
1487
+ rep_refresh = gr.Button("Refresh list")
1488
+ rep_load_latest = gr.Button("Load latest")
1489
+ rep_picker = gr.Dropdown(label="Select a report file", choices=[], interactive=True)
1490
+ report_view = gr.Markdown(value="_No report loaded yet._")
1491
+
1492
+ def _rep_refresh():
1493
+ files = list_summary_reports()
1494
+ return gr.update(choices=files, value=(files[0] if files else None))
1495
+
1496
+ def _rep_load_latest():
1497
+ files = list_summary_reports()
1498
+ if not files:
1499
+ return gr.update(value="_No reports found._")
1500
+ return read_report_text(files[0])
1501
+
1502
+ def _rep_load_pick(path):
1503
+ return read_report_text(path)
1504
+
1505
+ rep_refresh.click(_rep_refresh, inputs=None, outputs=rep_picker)
1506
+ rep_load_latest.click(_rep_load_latest, inputs=None, outputs=report_view)
1507
+ rep_picker.change(_rep_load_pick, inputs=rep_picker, outputs=report_view)
1508
+
1509
+
1510
+
1511
+ # --- Helpers bound to UI ---
1512
+ def ui_refresh(request: gr.Request):
1513
+ email = get_logged_in_email_from_request(request) or "(not logged in)"
1514
+ projects = list_projects_for_user(email) if "@" in email else []
1515
+ # pick default if available
1516
+ value = projects[0] if projects else ""
1517
+ return gr.update(choices=projects, value=value), f"**User:** {email}"
1518
+
1519
+ def ui_create(request: gr.Request, name: str):
1520
+ email = get_logged_in_email_from_request(request)
1521
+ if not (email and name.strip()):
1522
+ # keep the user label unchanged but tell them why
1523
+ label = f"**User:** {email or '(not logged in)'} \nCreate failed: missing email or project name."
1524
+ return gr.update(), label
1525
+ # touching the collection creates it if missing
1526
+ _ = get_collection(name, email)
1527
+ projects = list_projects_for_user(email)
1528
+ # update dropdown (choices + select the new project) AND refresh the user label
1529
+ label = f"**User:** {email} \nCreated project **{_slug(name)}**."
1530
+ return gr.update(choices=projects, value=name), label
1531
+
1532
+ def ui_delete(request: gr.Request, name: str):
1533
+ email = get_logged_in_email_from_request(request)
1534
+ if not (email and name):
1535
+ label = f"**User:** {email or '(not logged in)'} \nDelete failed."
1536
+ return gr.update(), label
1537
+
1538
+ ok = delete_project(email, name)
1539
+ msg = f"Deleted project **{name}**." if ok else "Delete failed or project not found."
1540
+ projects = list_projects_for_user(email)
1541
+ label = f"**User:** {email} \n{msg}"
1542
+ return gr.update(choices=projects, value=(projects[0] if projects else "")), label
1543
+
1544
+
1545
+
1546
+ # add `progress=gr.Progress(track_tqdm=True)` in the parameters
1547
+ def on_send(request: gr.Request, t, c, m, f, proj, prov, mdl, ocr, web, schem,
1548
+ progress=gr.Progress(track_tqdm=True)):
1549
+ chat_pairs, summary_path, trace_md_val, dag_img_val = \
1550
+ run_once(request, t, c, m, f, proj, prov, mdl, ocr, web, schem, progress=progress)
1551
+
1552
+ upload_reset = gr.update(value=None)
1553
+ msg_reset = gr.update(value="")
1554
+
1555
+ # 👇 show the file widget only when a report was generated this turn
1556
+ summary_upd = (gr.update(value=summary_path, visible=True)
1557
+ if summary_path else gr.update(value=None, visible=False))
1558
+
1559
+ return chat_pairs, summary_upd, trace_md_val, dag_img_val, upload_reset, msg_reset
1560
+
1561
+
1562
+ refresh_btn.click(ui_refresh, inputs=None, outputs=[project_dd, user_md])
1563
+ new_btn.click(ui_create, inputs=[new_name], outputs=[project_dd, user_md], preprocess=False)
1564
+ del_btn.click(ui_delete, inputs=[project_dd], outputs=[project_dd, user_md], preprocess=False)
1565
+
1566
+ send.click(on_send,
1567
+ inputs=[thread, chat, msg, files, project_dd, provider, model, ocr_backend, do_web_search, treat_as_schematic],
1568
+ outputs=[chat, summary, trace_md, dag_img, files, msg],
1569
+ concurrency_limit="default", concurrency_id="chat"
1570
+ )
1571
+ msg.submit(on_send,
1572
+ inputs=[thread, chat, msg, files, project_dd, provider, model, ocr_backend, do_web_search, treat_as_schematic],
1573
+ outputs=[chat, summary, trace_md, dag_img, files, msg],
1574
+ concurrency_limit="default", concurrency_id="chat"
1575
+ )
1576
+
1577
+ # Logout (call FastAPI /logout)
1578
+ # def do_logout():
1579
+ # import requests
1580
+ # try:
1581
+ # requests.post("/logout", timeout=3)
1582
+ # except Exception:
1583
+ # pass
1584
+ # return gr.update(value="**User:** (logged out)")
1585
+
1586
+ # logout_btn.click(do_logout, inputs=None, outputs=user_md)
1587
+ # 🔧 Auto-load the user & project list on page load (must be INSIDE Blocks)
1588
+ demo.load(ui_refresh, inputs=None, outputs=[project_dd, user_md])
1589
+ demo.queue(default_concurrency_limit=1, max_size=32) # serialize requests; prevents parallel actions during ingest
1590
+ return demo
1591
+
1592
+ def launch():
1593
+ # keep standalone mode for local runs (non-FastAPI)
1594
+ demo = build_gradio_blocks()
1595
+ demo.queue().launch(share=True)
1596
+
1597
+
1598
+ if __name__ == '__main__':
1599
+ launch()