Files changed (1) hide show
  1. app.py +101 -672
app.py CHANGED
@@ -1,695 +1,124 @@
1
- # vmm
2
- # Self-Sensing Concrete Assistant — Predictor (XGB) + Hybrid RAG
3
- # - Predictor tab: identical behavior to your "second code"
4
- # - Literature tab: from your "first code" (Hybrid RAG + MMR)
5
- # - Hugging Face friendly: online PDF fetching OFF by default
6
- # ================================================================
7
-
8
- # ---------------------- Runtime flags (HF-safe) ----------------------
9
- import os
10
- os.environ["TRANSFORMERS_NO_TF"] = "1"
11
- os.environ["TRANSFORMERS_NO_FLAX"] = "1"
12
- os.environ["TOKENIZERS_PARALLELISM"] = "false"
13
-
14
- # ------------------------------- Imports ------------------------------
15
- import re, time, joblib, warnings, json
16
- from pathlib import Path
17
- from typing import List, Dict, Any
18
-
19
- import numpy as np
20
- import pandas as pd
21
- import gradio as gr
22
-
23
- warnings.filterwarnings("ignore", category=UserWarning)
24
-
25
- # Optional deps (handled gracefully if missing)
26
- USE_DENSE = True
27
- try:
28
- from sentence_transformers import SentenceTransformer
29
- except Exception:
30
- USE_DENSE = False
31
-
32
- try:
33
- from rank_bm25 import BM25Okapi
34
- except Exception:
35
- BM25Okapi = None
36
- print("rank_bm25 not installed; BM25 disabled (TF-IDF still works).")
37
-
38
- # Optional OpenAI (for LLM paraphrase)
39
- OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
40
- OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini").strip().strip('"').strip("'")
41
-
42
- try:
43
- from openai import OpenAI
44
- except Exception:
45
- OpenAI = None
46
-
47
- print("openAI: ", OpenAI)
48
-
49
- # ========================= Predictor (kept same as 2nd) =========================
50
- CF_COL = "Conductive Filler Conc. (wt%)"
51
- TARGET_COL = "Stress GF (MPa-1)"
52
-
53
- MAIN_VARIABLES = [
54
- "Filler 1 Type",
55
- "Filler 1 Diameter (µm)",
56
- "Filler 1 Length (mm)",
57
- CF_COL,
58
- "Filler 1 Dimensionality",
59
- "Filler 2 Type",
60
- "Filler 2 Diameter (µm)",
61
- "Filler 2 Length (mm)",
62
- "Filler 2 Dimensionality",
63
- "Specimen Volume (mm3)",
64
- "Probe Count",
65
- "Probe Material",
66
- "W/B",
67
- "S/B",
68
- "Gauge Length (mm)",
69
- "Curing Condition",
70
- "Number of Fillers",
71
- "Drying Temperature (°C)",
72
- "Drying Duration (hr)",
73
- "Loading Rate (MPa/s)",
74
- "Modulus of Elasticity (GPa)",
75
- "Current Type",
76
- "Applied Voltage (V)"
77
- ]
78
-
79
- NUMERIC_COLS = {
80
- "Filler 1 Diameter (µm)",
81
- "Filler 1 Length (mm)",
82
- CF_COL,
83
- "Filler 2 Diameter (µm)",
84
- "Filler 2 Length (mm)",
85
- "Specimen Volume (mm3)",
86
- "Probe Count",
87
- "W/B",
88
- "S/B",
89
- "Gauge Length (mm)",
90
- "Number of Fillers",
91
- "Drying Temperature (°C)",
92
- "Drying Duration (hr)",
93
- "Loading Rate (MPa/s)",
94
- "Modulus of Elasticity (GPa)",
95
- "Applied Voltage (V)"
96
  }
97
 
98
- CATEGORICAL_COLS = {
99
- "Filler 1 Type",
100
- "Filler 1 Dimensionality",
101
- "Filler 2 Type",
102
- "Filler 2 Dimensionality",
103
- "Probe Material",
104
- "Curing Condition",
105
- "Current Type"
106
  }
107
 
108
- DIM_CHOICES = ["0D", "1D", "2D", "3D", "NA"]
109
- CURRENT_CHOICES = ["DC", "AC", "NA"]
110
-
111
- MODEL_CANDIDATES = [
112
- "stress_gf_xgb.joblib",
113
- "models/stress_gf_xgb.joblib",
114
- "/home/user/app/stress_gf_xgb.joblib",
115
- ]
116
-
117
- def _load_model_or_error():
118
- for p in MODEL_CANDIDATES:
119
- if os.path.exists(p):
120
- try:
121
- return joblib.load(p)
122
- except Exception as e:
123
- return f"Could not load model from {p}: {e}"
124
- return ("Model file not found. Upload your trained pipeline as "
125
- "stress_gf_xgb.joblib (or put it in models/).")
126
-
127
- def _coerce_to_row(form_dict: dict) -> pd.DataFrame:
128
- row = {}
129
- for col in MAIN_VARIABLES:
130
- v = form_dict.get(col, None)
131
- if col in NUMERIC_COLS:
132
- if v in ("", None):
133
- row[col] = np.nan
134
- else:
135
- try:
136
- row[col] = float(v)
137
- except Exception:
138
- row[col] = np.nan
139
- else:
140
- row[col] = "" if v in (None, "NA") else str(v).strip()
141
- return pd.DataFrame([row], columns=MAIN_VARIABLES)
142
-
143
- def predict_fn(**kwargs):
144
- mdl = _load_model_or_error()
145
- if isinstance(mdl, str):
146
- return mdl
147
- X_new = _coerce_to_row(kwargs)
148
- try:
149
- y_log = mdl.predict(X_new) # model predicts log1p(target)
150
- y = float(np.expm1(y_log)[0]) # back to original scale MPa^-1
151
- if -1e-10 < y < 0:
152
- y = 0.0
153
- return y
154
- except Exception as e:
155
- return f"Prediction error: {e}"
156
-
157
- EXAMPLE = {
158
- "Filler 1 Type": "CNT",
159
- "Filler 1 Dimensionality": "1D",
160
- "Filler 1 Diameter (µm)": 0.02,
161
- "Filler 1 Length (mm)": 1.2,
162
- CF_COL: 0.5,
163
- "Filler 2 Type": "",
164
- "Filler 2 Dimensionality": "NA",
165
- "Filler 2 Diameter (µm)": None,
166
- "Filler 2 Length (mm)": None,
167
- "Specimen Volume (mm3)": 1000,
168
- "Probe Count": 2,
169
- "Probe Material": "Copper",
170
- "W/B": 0.4,
171
- "S/B": 2.5,
172
- "Gauge Length (mm)": 20,
173
- "Curing Condition": "28d water, 20°C",
174
- "Number of Fillers": 1,
175
- "Drying Temperature (°C)": 60,
176
- "Drying Duration (hr)": 24,
177
- "Loading Rate (MPa/s)": 0.1,
178
- "Modulus of Elasticity (GPa)": 25,
179
- "Current Type": "DC",
180
- "Applied Voltage (V)": 5.0,
181
  }
182
 
183
- def _fill_example():
184
- return [EXAMPLE.get(k, None) for k in MAIN_VARIABLES]
185
-
186
- def _clear_all():
187
- cleared = []
188
- for col in MAIN_VARIABLES:
189
- if col in NUMERIC_COLS:
190
- cleared.append(None)
191
- elif col in {"Filler 1 Dimensionality", "Filler 2 Dimensionality"}:
192
- cleared.append("NA")
193
- elif col == "Current Type":
194
- cleared.append("NA")
195
- else:
196
- cleared.append("")
197
- return cleared
198
-
199
- # ========================= Hybrid RAG (from 1st code) =========================
200
- # Configuration
201
- ARTIFACT_DIR = Path("rag_artifacts"); ARTIFACT_DIR.mkdir(exist_ok=True)
202
- TFIDF_VECT_PATH = ARTIFACT_DIR / "tfidf_vectorizer.joblib"
203
- TFIDF_MAT_PATH = ARTIFACT_DIR / "tfidf_matrix.joblib"
204
- BM25_TOK_PATH = ARTIFACT_DIR / "bm25_tokens.joblib"
205
- EMB_NPY_PATH = ARTIFACT_DIR / "chunk_embeddings.npy"
206
- RAG_META_PATH = ARTIFACT_DIR / "chunks.parquet"
207
-
208
- # PDF source (HF-safe: rely on local /papers by default)
209
- LOCAL_PDF_DIR = Path("./literature_pdfs"); LOCAL_PDF_DIR.mkdir(exist_ok=True)
210
- USE_ONLINE_SOURCES = os.getenv("USE_ONLINE_SOURCES", "false").lower() == "true"
211
-
212
- # Retrieval weights
213
- W_TFIDF_DEFAULT = 0.50 if not USE_DENSE else 0.30
214
- W_BM25_DEFAULT = 0.50 if not USE_DENSE else 0.30
215
- W_EMB_DEFAULT = 0.00 if not USE_DENSE else 0.40
216
-
217
- # Simple text processing
218
- _SENT_SPLIT_RE = re.compile(r"(?<=[.!?])\s+|\n+")
219
- TOKEN_RE = re.compile(r"[A-Za-z0-9_#+\-/\.%]+")
220
- def sent_split(text: str) -> List[str]:
221
- sents = [s.strip() for s in _SENT_SPLIT_RE.split(text) if s.strip()]
222
- return [s for s in sents if len(s.split()) >= 5]
223
- def tokenize(text: str) -> List[str]:
224
- return [t.lower() for t in TOKEN_RE.findall(text)]
225
-
226
- # PDF text extraction (PyMuPDF preferred; pypdf fallback)
227
- def _extract_pdf_text(pdf_path: Path) -> str:
228
- try:
229
- import fitz
230
- doc = fitz.open(pdf_path)
231
- out = []
232
- for i, page in enumerate(doc):
233
- out.append(f"[[PAGE={i+1}]]\n{page.get_text('text') or ''}")
234
- return "\n\n".join(out)
235
- except Exception:
236
- try:
237
- from pypdf import PdfReader
238
- reader = PdfReader(str(pdf_path))
239
- out = []
240
- for i, p in enumerate(reader.pages):
241
- txt = p.extract_text() or ""
242
- out.append(f"[[PAGE={i+1}]]\n{txt}")
243
- return "\n\n".join(out)
244
- except Exception as e:
245
- print(f"PDF read error ({pdf_path}): {e}")
246
- return ""
247
-
248
- def chunk_by_sentence_windows(text: str, win_size=8, overlap=2) -> List[str]:
249
- sents = sent_split(text)
250
- chunks, step = [], max(1, win_size - overlap)
251
- for i in range(0, len(sents), step):
252
- window = sents[i:i+win_size]
253
- if not window: break
254
- chunks.append(" ".join(window))
255
- return chunks
256
-
257
- def _safe_init_st_model(name: str):
258
- global USE_DENSE
259
- if not USE_DENSE:
260
- return None
261
- try:
262
- return SentenceTransformer(name)
263
- except Exception as e:
264
- print("Dense embeddings unavailable:", e)
265
- USE_DENSE = False
266
- return None
267
-
268
- # Build or load index
269
- def build_or_load_hybrid(pdf_dir: Path):
270
- have_cache = (TFIDF_VECT_PATH.exists() and TFIDF_MAT_PATH.exists()
271
- and RAG_META_PATH.exists()
272
- and (BM25_TOK_PATH.exists() or BM25Okapi is None)
273
- and (EMB_NPY_PATH.exists() or not USE_DENSE))
274
- if have_cache:
275
- vectorizer = joblib.load(TFIDF_VECT_PATH)
276
- X_tfidf = joblib.load(TFIDF_MAT_PATH)
277
- meta = pd.read_parquet(RAG_META_PATH)
278
- bm25_toks = joblib.load(BM25_TOK_PATH) if BM25Okapi is not None else None
279
- emb = np.load(EMB_NPY_PATH) if (USE_DENSE and EMB_NPY_PATH.exists()) else None
280
- return vectorizer, X_tfidf, meta, bm25_toks, emb
281
-
282
- rows, all_tokens = [], []
283
- pdf_paths = list(Path(pdf_dir).glob("**/*.pdf"))
284
- print(f"Indexing PDFs in {pdf_dir} — found {len(pdf_paths)} files.")
285
- for pdf in pdf_paths:
286
- raw = _extract_pdf_text(pdf)
287
- if not raw.strip():
288
- continue
289
- for i, ch in enumerate(chunk_by_sentence_windows(raw, win_size=8, overlap=2)):
290
- rows.append({"doc_path": str(pdf), "chunk_id": i, "text": ch})
291
- all_tokens.append(tokenize(ch))
292
- if not rows:
293
- # create empty stub to avoid crashes; UI will message user to upload PDFs
294
- meta = pd.DataFrame(columns=["doc_path", "chunk_id", "text"])
295
- vectorizer = None; X_tfidf = None; emb = None; all_tokens = None
296
- return vectorizer, X_tfidf, meta, all_tokens, emb
297
-
298
- meta = pd.DataFrame(rows)
299
-
300
- from sklearn.feature_extraction.text import TfidfVectorizer
301
- vectorizer = TfidfVectorizer(
302
- ngram_range=(1,2),
303
- min_df=1, max_df=0.95,
304
- sublinear_tf=True, smooth_idf=True,
305
- lowercase=True,
306
- token_pattern=r"(?u)\b\w[\w\-\./%+#]*\b"
307
- )
308
- X_tfidf = vectorizer.fit_transform(meta["text"].tolist())
309
-
310
- emb = None
311
- if USE_DENSE:
312
- try:
313
- st_model = _safe_init_st_model(os.getenv("EMB_MODEL_NAME", "sentence-transformers/all-MiniLM-L6-v2"))
314
- if st_model is not None:
315
- from sklearn.preprocessing import normalize as sk_normalize
316
- em = st_model.encode(meta["text"].tolist(), batch_size=64, show_progress_bar=False, convert_to_numpy=True)
317
- emb = sk_normalize(em)
318
- np.save(EMB_NPY_PATH, emb)
319
- except Exception as e:
320
- print("Dense embedding failed:", e)
321
- emb = None
322
-
323
- # Save artifacts
324
- joblib.dump(vectorizer, TFIDF_VECT_PATH)
325
- joblib.dump(X_tfidf, TFIDF_MAT_PATH)
326
- if BM25Okapi is not None:
327
- joblib.dump(all_tokens, BM25_TOK_PATH)
328
- meta.to_parquet(RAG_META_PATH, index=False)
329
-
330
- return vectorizer, X_tfidf, meta, all_tokens, emb
331
-
332
- tfidf_vectorizer, tfidf_matrix, rag_meta, bm25_tokens, emb_matrix = build_or_load_hybrid(LOCAL_PDF_DIR)
333
- bm25 = BM25Okapi(bm25_tokens) if (BM25Okapi is not None and bm25_tokens is not None) else None
334
- st_query_model = _safe_init_st_model(os.getenv("EMB_MODEL_NAME", "sentence-transformers/all-MiniLM-L6-v2"))
335
-
336
- def _extract_page(text_chunk: str) -> str:
337
- m = list(re.finditer(r"\[\[PAGE=(\d+)\]\]", text_chunk or ""))
338
- return (m[-1].group(1) if m else "?")
339
-
340
- def hybrid_search(query: str, k=8, w_tfidf=W_TFIDF_DEFAULT, w_bm25=W_BM25_DEFAULT, w_emb=W_EMB_DEFAULT):
341
- if rag_meta is None or rag_meta.empty:
342
- return pd.DataFrame()
343
-
344
- # Dense scores
345
- if USE_DENSE and st_query_model is not None and emb_matrix is not None and w_emb > 0:
346
- try:
347
- from sklearn.preprocessing import normalize as sk_normalize
348
- q_emb = st_query_model.encode([query], convert_to_numpy=True)
349
- q_emb = sk_normalize(q_emb)[0]
350
- dense_scores = emb_matrix @ q_emb
351
- except Exception as e:
352
- print("Dense query encoding failed:", e)
353
- dense_scores = np.zeros(len(rag_meta), dtype=float); w_emb = 0.0
354
- else:
355
- dense_scores = np.zeros(len(rag_meta), dtype=float); w_emb = 0.0
356
-
357
- # TF-IDF scores
358
- if tfidf_vectorizer is not None and tfidf_matrix is not None:
359
- q_vec = tfidf_vectorizer.transform([query])
360
- tfidf_scores = (tfidf_matrix @ q_vec.T).toarray().ravel()
361
- else:
362
- tfidf_scores = np.zeros(len(rag_meta), dtype=float); w_tfidf = 0.0
363
-
364
- # BM25 scores
365
- if bm25 is not None:
366
- q_tokens = [t.lower() for t in re.findall(r"[A-Za-z0-9_#+\-/\.%]+", query)]
367
- bm25_scores = np.array(bm25.get_scores(q_tokens), dtype=float)
368
- else:
369
- bm25_scores = np.zeros(len(rag_meta), dtype=float); w_bm25 = 0.0
370
-
371
- def _norm(x):
372
- x = np.asarray(x, dtype=float)
373
- if np.allclose(x.max(), x.min()):
374
- return np.zeros_like(x)
375
- return (x - x.min()) / (x.max() - x.min())
376
-
377
- s_dense = _norm(dense_scores)
378
- s_tfidf = _norm(tfidf_scores)
379
- s_bm25 = _norm(bm25_scores)
380
-
381
- total_w = (w_tfidf + w_bm25 + w_emb) or 1.0
382
- w_tfidf, w_bm25, w_emb = w_tfidf/total_w, w_bm25/total_w, w_emb/total_w
383
-
384
- combo = w_emb * s_dense + w_tfidf * s_tfidf + w_bm25 * s_bm25
385
- idx = np.argsort(-combo)[:k]
386
- hits = rag_meta.iloc[idx].copy()
387
- hits["score_dense"] = s_dense[idx]
388
- hits["score_tfidf"] = s_tfidf[idx]
389
- hits["score_bm25"] = s_bm25[idx]
390
- hits["score"] = combo[idx]
391
- return hits.reset_index(drop=True)
392
-
393
- def split_sentences(text: str) -> List[str]:
394
- sents = sent_split(text)
395
- return [s for s in sents if 6 <= len(s.split()) <= 60]
396
-
397
- def mmr_select_sentences(question: str, hits: pd.DataFrame, top_n=4, pool_per_chunk=6, lambda_div=0.7):
398
- pool = []
399
- for _, row in hits.iterrows():
400
- doc = Path(row["doc_path"]).name
401
- page = _extract_page(row["text"])
402
- for s in split_sentences(row["text"])[:pool_per_chunk]:
403
- pool.append({"sent": s, "doc": doc, "page": page})
404
- if not pool:
405
- return []
406
-
407
- sent_texts = [p["sent"] for p in pool]
408
-
409
- # Embedding-based relevance if available, else TF-IDF
410
- use_dense = USE_DENSE and st_query_model is not None
411
- if use_dense:
412
- try:
413
- from sklearn.preprocessing import normalize as sk_normalize
414
- texts = [question] + sent_texts
415
- enc = st_query_model.encode(texts, convert_to_numpy=True)
416
- q_vec = sk_normalize(enc[:1])[0]
417
- S = sk_normalize(enc[1:])
418
- rel = (S @ q_vec)
419
- def sim_fn(i, j): return float(S[i] @ S[j])
420
- except Exception:
421
- use_dense = False
422
-
423
- if not use_dense:
424
- from sklearn.feature_extraction.text import TfidfVectorizer
425
- vect = TfidfVectorizer().fit(sent_texts + [question])
426
- Q = vect.transform([question]); S = vect.transform(sent_texts)
427
- rel = (S @ Q.T).toarray().ravel()
428
- def sim_fn(i, j): return float((S[i] @ S[j].T).toarray()[0, 0])
429
-
430
- selected, selected_idx = [], []
431
- remain = list(range(len(pool)))
432
- first = int(np.argmax(rel))
433
- selected.append(pool[first]); selected_idx.append(first); remain.remove(first)
434
-
435
- while len(selected) < top_n and remain:
436
- cand_scores = []
437
- for i in remain:
438
- sim_to_sel = max(sim_fn(i, j) for j in selected_idx) if selected_idx else 0.0
439
- score = lambda_div * rel[i] - (1 - lambda_div) * sim_to_sel
440
- cand_scores.append((score, i))
441
- cand_scores.sort(reverse=True)
442
- best_i = cand_scores[0][1]
443
- selected.append(pool[best_i]); selected_idx.append(best_i); remain.remove(best_i)
444
- return selected
445
-
446
- def compose_extractive(selected: List[Dict[str, Any]]) -> str:
447
- if not selected:
448
- return ""
449
- return " ".join(f"{s['sent']} ({s['doc']}, p.{s['page']})" for s in selected)
450
-
451
- def synthesize_with_llm(question: str, sentence_lines: List[str], model: str = None, temperature: float = 0.2) -> str:
452
- if OPENAI_API_KEY is None or OpenAI is None:
453
- return None
454
- print("calling LLM api")
455
- client = OpenAI(api_key=OPENAI_API_KEY)
456
- model = model or OPENAI_MODEL
457
- print("using: ", model)
458
- SYSTEM_PROMPT = (
459
- "You are a scientific assistant for self-sensing cementitious materials.\n"
460
- "Answer STRICTLY using the provided sentences.\n"
461
- "Do not invent facts. Keep it concise (3–6 sentences).\n"
462
- "Retain inline citations like (Doc.pdf, p.X) exactly as given."
463
- )
464
- user_prompt = (
465
- f"Question: {question}\n\n"
466
- f"Use ONLY these sentences to answer; keep their inline citations:\n" +
467
- "\n".join(f"- {s}" for s in sentence_lines)
468
- )
469
- try:
470
- resp = client.responses.create(
471
- model=model,
472
- input=[
473
- {"role": "system", "content": SYSTEM_PROMPT},
474
- {"role": "user", "content": user_prompt},
475
- ],
476
- temperature=temperature,
477
- )
478
- print(resp.output_text)
479
- return getattr(resp, "output_text", None) or str(resp)
480
- except Exception as e:
481
- print("error in LLM synthesis:", e)
482
- return None
483
 
 
 
 
 
 
 
 
 
 
 
 
484
 
485
- def rag_reply(
486
- question: str,
487
- k: int = 8,
488
- n_sentences: int = 4,
489
- include_passages: bool = False,
490
- use_llm: bool = True,
491
- model: str = "gpt-4o-mini",
492
- temperature: float = 0.2,
493
- strict_quotes_only: bool = False,
494
- w_tfidf: float = W_TFIDF_DEFAULT,
495
- w_bm25: float = W_BM25_DEFAULT,
496
- w_emb: float = W_EMB_DEFAULT
497
- ) -> str:
498
- hits = hybrid_search(question, k=k, w_tfidf=w_tfidf, w_bm25=w_bm25, w_emb=w_emb)
499
- if hits is None or hits.empty:
500
- return "No indexed PDFs found. Upload PDFs to the 'papers/' folder and reload the Space."
501
 
502
- selected = mmr_select_sentences(question, hits, top_n=int(n_sentences), pool_per_chunk=6, lambda_div=0.7)
503
- header_cites = "; ".join(f"{Path(r['doc_path']).name} (p.{_extract_page(r['text'])})" for _, r in hits.head(6).iterrows())
504
- srcs = {Path(r['doc_path']).name for _, r in hits.iterrows()}
505
- coverage_note = "" if len(srcs) >= 3 else f"\n\n> Note: Only {len(srcs)} unique source(s) contributed. Add more PDFs or increase Top-K."
 
 
506
 
507
- if strict_quotes_only:
508
- if not selected:
509
- return f"**Quoted Passages:**\n\n---\n" + "\n\n".join(hits['text'].tolist()[:2]) + f"\n\n**Citations:** {header_cites}{coverage_note}"
510
- msg = "**Quoted Passages:**\n- " + "\n- ".join(f"{s['sent']} ({s['doc']}, p.{s['page']})" for s in selected)
511
- msg += f"\n\n**Citations:** {header_cites}{coverage_note}"
512
- if include_passages:
513
- msg += "\n\n---\n" + "\n\n".join(hits['text'].tolist()[:2])
514
- return msg
515
 
516
- extractive = compose_extractive(selected)
517
- if use_llm and selected:
518
- lines = [f"{s['sent']} ({s['doc']}, p.{s['page']})" for s in selected]
519
- llm_text = synthesize_with_llm(question, lines, model=model, temperature=temperature)
520
- if llm_text:
521
- msg = f"**Answer (LLM synthesis):** {llm_text}\n\n**Citations:** {header_cites}{coverage_note}"
522
- if include_passages:
523
- msg += "\n\n---\n" + "\n\n".join(hits['text'].tolist()[:2])
524
- return msg
525
 
526
- if not extractive:
527
- return f"**Answer:** Here are relevant passages.\n\n**Citations:** {header_cites}{coverage_note}\n\n---\n" + "\n\n".join(hits['text'].tolist()[:2])
 
 
 
 
 
 
 
 
 
528
 
529
- msg = f"**Answer:** {extractive}\n\n**Citations:** {header_cites}{coverage_note}"
530
- if include_passages:
531
- msg += "\n\n---\n" + "\n\n".join(hits['text'].tolist()[:2])
532
- return msg
 
 
 
533
 
534
- def rag_chat_fn(message, history, top_k, n_sentences, include_passages,
535
- use_llm, model_name, temperature, strict_quotes_only,
536
- w_tfidf, w_bm25, w_emb):
537
- if not message or not message.strip():
538
- return "Ask a literature question (e.g., *How does CNT length affect gauge factor?*)"
539
- try:
540
- return rag_reply(
541
- question=message,
542
- k=int(top_k),
543
- n_sentences=int(n_sentences),
544
- include_passages=bool(include_passages),
545
- use_llm=bool(use_llm),
546
- model=(model_name or None),
547
- temperature=float(temperature),
548
- strict_quotes_only=bool(strict_quotes_only),
549
- w_tfidf=float(w_tfidf),
550
- w_bm25=float(w_bm25),
551
- w_emb=float(w_emb),
552
- )
553
- except Exception as e:
554
- return f"RAG error: {e}"
555
 
556
- # ========================= UI (predictor styling kept) =========================
557
- CSS = """
558
- /* Blue to green gradient background */
559
- .gradio-container {
560
- background: linear-gradient(135deg, #1e3a8a 0%, #166534 60%, #15803d 100%) !important;
561
  }
562
- * {font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;}
563
- .card {background: rgba(255,255,255,0.07) !important; border: 1px solid rgba(255,255,255,0.12);}
564
- label.svelte-1ipelgc {color: #e0f2fe !important;}
565
  """
566
 
 
567
  theme = gr.themes.Soft(
568
  primary_hue="blue",
569
- neutral_hue="green"
 
570
  ).set(
571
- body_background_fill="#1e3a8a",
572
- body_text_color="#e0f2fe",
573
- input_background_fill="#172554",
574
- input_border_color="#1e40af",
575
- button_primary_background_fill="#2563eb",
576
  button_primary_text_color="#ffffff",
577
- button_secondary_background_fill="#14532d",
578
- button_secondary_text_color="#ecfdf5",
 
 
579
  )
580
-
581
- with gr.Blocks(css=CSS, theme=theme, fill_height=True) as demo:
582
- gr.Markdown(
583
- "<h1 style='margin:0'>Self-Sensing Concrete Assistant</h1>"
584
- "<p style='opacity:.9'>"
585
- "Left tab: ML prediction for Stress Gauge Factor (kept identical to your deployed predictor). "
586
- "Right tab: Literature Q&A via Hybrid RAG (BM25 + TF-IDF + optional dense) with MMR sentence selection. "
587
- "Upload PDFs into <code>papers/</code> in your Space repo."
588
- "</p>"
589
- )
590
-
591
- with gr.Tabs():
592
- # ------------------------- Predictor Tab -------------------------
593
- with gr.Tab("🔮 Predict Gauge Factor (XGB)"):
594
- with gr.Row():
595
- with gr.Column(scale=7):
596
- with gr.Accordion("Primary conductive filler", open=True, elem_classes=["card"]):
597
- f1_type = gr.Textbox(label="Filler 1 Type", placeholder="e.g., CNT, Graphite, Steel fiber")
598
- f1_diam = gr.Number(label="Filler 1 Diameter (µm)")
599
- f1_len = gr.Number(label="Filler 1 Length (mm)")
600
- cf_conc = gr.Number(label=f"{CF_COL}", info="Weight percent of total binder")
601
- f1_dim = gr.Dropdown(DIM_CHOICES, value="NA", label="Filler 1 Dimensionality")
602
-
603
- with gr.Accordion("Secondary filler (optional)", open=False, elem_classes=["card"]):
604
- f2_type = gr.Textbox(label="Filler 2 Type", placeholder="Optional")
605
- f2_diam = gr.Number(label="Filler 2 Diameter (µm)")
606
- f2_len = gr.Number(label="Filler 2 Length (mm)")
607
- f2_dim = gr.Dropdown(DIM_CHOICES, value="NA", label="Filler 2 Dimensionality")
608
-
609
- with gr.Accordion("Mix design & specimen", open=False, elem_classes=["card"]):
610
- spec_vol = gr.Number(label="Specimen Volume (mm3)")
611
- probe_cnt = gr.Number(label="Probe Count")
612
- probe_mat = gr.Textbox(label="Probe Material", placeholder="e.g., Copper, Silver paste")
613
- wb = gr.Number(label="W/B")
614
- sb = gr.Number(label="S/B")
615
- gauge_len = gr.Number(label="Gauge Length (mm)")
616
- curing = gr.Textbox(label="Curing Condition", placeholder="e.g., 28d water, 20°C")
617
- n_fillers = gr.Number(label="Number of Fillers")
618
-
619
- with gr.Accordion("Processing", open=False, elem_classes=["card"]):
620
- dry_temp = gr.Number(label="Drying Temperature (°C)")
621
- dry_hrs = gr.Number(label="Drying Duration (hr)")
622
-
623
- with gr.Accordion("Mechanical & electrical loading", open=False, elem_classes=["card"]):
624
- load_rate = gr.Number(label="Loading Rate (MPa/s)")
625
- E_mod = gr.Number(label="Modulus of Elasticity (GPa)")
626
- current = gr.Dropdown(CURRENT_CHOICES, value="NA", label="Current Type")
627
- voltage = gr.Number(label="Applied Voltage (V)")
628
-
629
- with gr.Column(scale=5):
630
- with gr.Group(elem_classes=["card"]):
631
- out_pred = gr.Number(label="Predicted Stress GF (MPa-1)", precision=6)
632
- with gr.Row():
633
- btn_pred = gr.Button("Predict", variant="primary")
634
- btn_clear = gr.Button("Clear")
635
- btn_demo = gr.Button("Fill Example")
636
-
637
- with gr.Accordion("About this model", open=False, elem_classes=["card"]):
638
- gr.Markdown(
639
- "- Pipeline: ColumnTransformer -> (RobustScaler + OneHot) -> XGBoost\n"
640
- "- Target: Stress GF (MPa^-1) on original scale (model trains on log1p).\n"
641
- "- Missing values are safely imputed per-feature.\n"
642
- "- Trained columns:\n"
643
- f" `{', '.join(MAIN_VARIABLES)}`"
644
- )
645
-
646
- # Wire predictor buttons
647
- inputs_in_order = [
648
- f1_type, f1_diam, f1_len, cf_conc,
649
- f1_dim, f2_type, f2_diam, f2_len,
650
- f2_dim, spec_vol, probe_cnt, probe_mat,
651
- wb, sb, gauge_len, curing, n_fillers,
652
- dry_temp, dry_hrs, load_rate,
653
- E_mod, current, voltage
654
- ]
655
-
656
- def _predict_wrapper(*vals):
657
- data = {k: v for k, v in zip(MAIN_VARIABLES, vals)}
658
- return predict_fn(**data)
659
-
660
- btn_pred.click(_predict_wrapper, inputs=inputs_in_order, outputs=out_pred)
661
- btn_clear.click(lambda: _clear_all(), inputs=None, outputs=inputs_in_order)
662
- btn_demo.click(lambda: _fill_example(), inputs=None, outputs=inputs_in_order)
663
-
664
- # ------------------------- Literature Tab -------------------------
665
- with gr.Tab("📚 Ask the Literature (Hybrid RAG + MMR)"):
666
- gr.Markdown(
667
- "Upload PDFs into the repository folder <code>papers/</code> then reload the Space. "
668
- "Answers cite (Doc.pdf, p.X). Toggle strict quotes or optional LLM paraphrasing."
669
- )
670
- with gr.Row():
671
- top_k = gr.Slider(5, 12, value=8, step=1, label="Top-K chunks")
672
- n_sentences = gr.Slider(2, 6, value=4, step=1, label="Answer length (sentences)")
673
- include_passages = gr.Checkbox(value=False, label="Include supporting passages")
674
- with gr.Accordion("Retriever weights (advanced)", open=False):
675
- w_tfidf = gr.Slider(0.0, 1.0, value=W_TFIDF_DEFAULT, step=0.05, label="TF-IDF weight")
676
- w_bm25 = gr.Slider(0.0, 1.0, value=W_BM25_DEFAULT, step=0.05, label="BM25 weight")
677
- w_emb = gr.Slider(0.0, 1.0, value=W_EMB_DEFAULT, step=0.05, label="Dense weight (set 0 if disabled)")
678
- with gr.Accordion("LLM & Controls", open=False):
679
- strict_quotes_only = gr.Checkbox(value=False, label="Strict quotes only (no paraphrasing)")
680
- use_llm = gr.Checkbox(value=True, label="Use LLM to paraphrase selected sentences")
681
- model_name = gr.Textbox(value=os.getenv("OPENAI_MODEL", OPENAI_MODEL),
682
- label="LLM model", placeholder="e.g., gpt-5 or gpt-5-mini")
683
- temperature = gr.Slider(0.0, 1.0, value=0.2, step=0.05, label="Temperature")
684
- gr.ChatInterface(
685
- fn=rag_chat_fn,
686
- additional_inputs=[top_k, n_sentences, include_passages, use_llm, model_name,
687
- temperature, strict_quotes_only, w_tfidf, w_bm25, w_emb],
688
- title="Literature Q&A",
689
- description="Hybrid retrieval with diversity. Answers carry inline (Doc, p.X) citations. Toggle strict/LLM modes."
690
- )
691
-
692
- # ------------- Launch -------------
693
- if __name__ == "__main__":
694
- # queue() helps HF Spaces with concurrency; show_error suggests upload PDFs if none
695
- demo.queue().launch()
 
1
+ # ========================= UI (predictor styling kept) =========================
2
+ CSS = """
3
+ /* ----- Base layout: light, high-contrast ----- */
4
+ .gradio-container {
5
+ background: #f8fafc !important; /* slate-50 */
6
+ color: #0f172a !important; /* slate-900 */
7
+ --card-bg: #ffffff;
8
+ --card-brd: #e2e8f0; /* slate-200 */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  }
10
 
11
+ /* Cards / groups / accordions */
12
+ .card, .gr-accordion, .gr-group, .gr-box {
13
+ background: var(--card-bg) !important;
14
+ border: 1px solid var(--card-brd) !important;
15
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
16
+ border-radius: 14px !important;
 
 
17
  }
18
 
19
+ /* Inputs: white background, dark text */
20
+ .gradio-container input,
21
+ .gradio-container textarea,
22
+ .gradio-container select,
23
+ .gradio-container .gr-input,
24
+ .gradio-container .gr-textbox textarea {
25
+ background: #ffffff !important;
26
+ color: #0f172a !important;
27
+ border: 1px solid #cbd5e1 !important; /* slate-300 */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  }
29
 
30
+ /* Buttons */
31
+ .gradio-container button {
32
+ font-weight: 700 !important;
33
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
+ /* ----- Label colors by component type (Blue / Green / Red) ----- */
36
+ /* Blue: text-like fields & sliders */
37
+ .gradio-container .gr-textbox label,
38
+ .gradio-container .gr-markdown h1,
39
+ .gradio-container .gr-markdown h2,
40
+ .gradio-container .gr-markdown h3,
41
+ .gradio-container .gr-slider label {
42
+ color: #1d4ed8 !important; /* blue-700 */
43
+ font-weight: 700 !important;
44
+ text-shadow: 0 0 0.01px rgba(29,78,216,0.3);
45
+ }
46
 
47
+ /* Green: selections & toggles */
48
+ .gradio-container .gr-dropdown label,
49
+ .gradio-container .gr-checkbox label,
50
+ .gradio-container .gr-checkbox-group label {
51
+ color: #166534 !important; /* green-800 */
52
+ font-weight: 700 !important;
53
+ text-shadow: 0 0 0.01px rgba(22,101,52,0.3);
54
+ }
 
 
 
 
 
 
 
 
55
 
56
+ /* Red: numeric/measurement inputs (to stand out) */
57
+ .gradio-container .gr-number label {
58
+ color: #b91c1c !important; /* red-700 */
59
+ font-weight: 800 !important;
60
+ text-shadow: 0 0 0.01px rgba(185,28,28,0.25);
61
+ }
62
 
63
+ /* Secondary hint/info text under labels */
64
+ .gradio-container .label > .text-gray-500,
65
+ .gradio-container .label .secondary-text,
66
+ .gradio-container .gr-input .text-gray-500 {
67
+ color: #334155 !important; /* slate-700 */
68
+ }
 
 
69
 
70
+ /* Tabs: clearer selected state */
71
+ .gradio-container .tabs .tabitem.selected {
72
+ border-bottom: 3px solid #1d4ed8 !important; /* blue underline */
73
+ font-weight: 800 !important;
74
+ }
 
 
 
 
75
 
76
+ /* Chat bubbles: better contrast */
77
+ .gradio-container .message.user {
78
+ background: #e0f2fe !important; /* sky-100 */
79
+ border: 1px solid #bae6fd !important;
80
+ color: #0c4a6e !important;
81
+ }
82
+ .gradio-container .message.bot {
83
+ background: #ecfdf5 !important; /* emerald-50 */
84
+ border: 1px solid #d1fae5 !important;
85
+ color: #064e3b !important;
86
+ }
87
 
88
+ /* Sliders & focus states */
89
+ .gradio-container input:focus,
90
+ .gradio-container textarea:focus,
91
+ .gradio-container select:focus {
92
+ outline: 2px solid #1d4ed8 !important; /* blue focus ring */
93
+ border-color: #1d4ed8 !important;
94
+ }
95
 
96
+ /* Headline block at top */
97
+ .gradio-container h1, .gradio-container .prose h1 {
98
+ color: #0f172a !important;
99
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
+ /* Small bump to label size */
102
+ .gradio-container label {
103
+ font-size: 0.98rem !important;
104
+ letter-spacing: 0.1px;
 
105
  }
 
 
 
106
  """
107
 
108
+ # Tailwind-like hues mapped into Gradio theme tokens
109
  theme = gr.themes.Soft(
110
  primary_hue="blue",
111
+ secondary_hue="green",
112
+ neutral_hue="slate"
113
  ).set(
114
+ body_background_fill="#f8fafc",
115
+ body_text_color="#0f172a",
116
+ input_background_fill="#ffffff",
117
+ input_border_color="#cbd5e1",
118
+ button_primary_background_fill="#1d4ed8", # blue
119
  button_primary_text_color="#ffffff",
120
+ button_secondary_background_fill="#16a34a", # green
121
+ button_secondary_text_color="#ffffff",
122
+ radius_large="14px",
123
+ spacing_size="8px"
124
  )