Spaces:
Sleeping
Sleeping
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()
|