OmarOmar91 commited on
Commit
30d819b
·
verified ·
1 Parent(s): fd7d7f6

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +649 -143
app.py CHANGED
@@ -1,8 +1,12 @@
1
  # ================================================================
2
  # Self-Sensing Concrete Assistant — Predictor (XGB) + Hybrid RAG
3
- # - Predictor tab: identical behavior (kept)
4
- # - Literature tab: Hybrid RAG; LLM runs silently when available
5
- # - UX: no visible "LLM & Controls" window; prediction=0.0 if incomplete
 
 
 
 
6
  # ================================================================
7
 
8
  # ---------------------- Runtime flags (HF-safe) ----------------------
@@ -12,9 +16,9 @@ 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
@@ -35,7 +39,7 @@ 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-5")
41
  try:
@@ -49,6 +53,7 @@ LLM_AVAILABLE = (OPENAI_API_KEY is not None and OPENAI_API_KEY.strip() != "" and
49
  # ========================= Predictor (kept) =========================
50
  CF_COL = "Conductive Filler Conc. (wt%)"
51
  TARGET_COL = "Stress GF (MPa-1)"
 
52
 
53
  MAIN_VARIABLES = [
54
  "Filler 1 Type",
@@ -105,84 +110,133 @@ CATEGORICAL_COLS = {
105
  "Current Type"
106
  }
107
 
108
- OPTIONAL_FIELDS = {
109
- "Filler 2 Type",
110
- "Filler 2 Diameter (µm)",
111
- "Filler 2 Length (mm)",
112
- "Filler 2 Dimensionality",
113
- }
114
-
115
- DIM_CHOICES = ["0D", "1D", "2D", "3D", "NA"]
116
- CURRENT_CHOICES = ["DC", "AC", "NA"]
117
 
118
  MODEL_CANDIDATES = [
119
  "stress_gf_xgb.joblib",
120
  "models/stress_gf_xgb.joblib",
121
  "/home/user/app/stress_gf_xgb.joblib",
 
122
  ]
123
 
124
- def _load_model_or_error():
125
- for p in MODEL_CANDIDATES:
 
 
 
 
 
126
  if os.path.exists(p):
127
  try:
128
- return joblib.load(p)
 
 
 
129
  except Exception as e:
130
- return f"Could not load model from {p}: {e}"
131
- return ("Model file not found. Upload your trained pipeline as "
132
- "stress_gf_xgb.joblib (or put it in models/).")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
  def _coerce_to_row(form_dict: dict) -> pd.DataFrame:
135
  row = {}
136
  for col in MAIN_VARIABLES:
137
  v = form_dict.get(col, None)
138
  if col in NUMERIC_COLS:
139
- if v in ("", None):
140
- row[col] = np.nan
141
- else:
142
- try:
143
- row[col] = float(v)
144
- except Exception:
145
- row[col] = np.nan
146
  else:
147
- row[col] = "" if v in (None, "NA") else str(v).strip()
 
148
  return pd.DataFrame([row], columns=MAIN_VARIABLES)
149
 
150
- def _is_complete(form_dict: dict) -> bool:
151
- for col in MAIN_VARIABLES:
152
- if col in OPTIONAL_FIELDS:
153
- continue
154
- v = form_dict.get(col, None)
155
- if col in NUMERIC_COLS:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  try:
157
- if v in ("", None) or (isinstance(v, float) and np.isnan(v)):
158
- return False
 
 
 
 
 
159
  except Exception:
160
- return False
161
- elif col in CATEGORICAL_COLS:
162
- s = "" if v in (None, "NA") else str(v).strip()
163
- if s == "":
164
- return False
165
- else:
166
- s = "" if v is None else str(v).strip()
167
- if s == "":
168
- return False
169
- return True
170
 
171
  def predict_fn(**kwargs):
172
- if not _is_complete(kwargs):
 
 
 
 
 
 
173
  return 0.0
174
- mdl = _load_model_or_error()
175
- if isinstance(mdl, str):
176
- return mdl
177
  X_new = _coerce_to_row(kwargs)
 
178
  try:
179
- y_log = mdl.predict(X_new) # model predicts log1p(target)
180
- y = float(np.expm1(y_log)[0]) # back to original scale MPa^-1
181
- if -1e-10 < y < 0:
182
- y = 0.0
183
- return y
 
 
184
  except Exception as e:
185
- return f"Prediction error: {e}"
 
 
186
 
187
  EXAMPLE = {
188
  "Filler 1 Type": "CNT",
@@ -191,7 +245,7 @@ EXAMPLE = {
191
  "Filler 1 Length (mm)": 1.2,
192
  CF_COL: 0.5,
193
  "Filler 2 Type": "",
194
- "Filler 2 Dimensionality": "NA",
195
  "Filler 2 Diameter (µm)": None,
196
  "Filler 2 Length (mm)": None,
197
  "Specimen Volume (mm3)": 1000,
@@ -219,9 +273,9 @@ def _clear_all():
219
  if col in NUMERIC_COLS:
220
  cleared.append(None)
221
  elif col in {"Filler 1 Dimensionality", "Filler 2 Dimensionality"}:
222
- cleared.append("NA")
223
  elif col == "Current Type":
224
- cleared.append("NA")
225
  else:
226
  cleared.append("")
227
  return cleared
@@ -291,6 +345,7 @@ def _safe_init_st_model(name: str):
291
  return None
292
 
293
  def build_or_load_hybrid(pdf_dir: Path):
 
294
  have_cache = (TFIDF_VECT_PATH.exists() and TFIDF_MAT_PATH.exists()
295
  and RAG_META_PATH.exists()
296
  and (BM25_TOK_PATH.exists() or BM25Okapi is None)
@@ -343,7 +398,7 @@ def build_or_load_hybrid(pdf_dir: Path):
343
  emb = None
344
 
345
  joblib.dump(vectorizer, TFIDF_VECT_PATH)
346
- joblib.dump(X_tfidF:=X_tfidf, TFIDF_MAT_PATH) # assign + save
347
  if BM25Okapi is not None:
348
  joblib.dump(all_tokens, BM25_TOK_PATH)
349
  meta.to_parquet(RAG_META_PATH, index=False)
@@ -354,9 +409,29 @@ bm25 = BM25Okapi(bm25_tokens) if (BM25Okapi is not None and bm25_tokens is not N
354
  st_query_model = _safe_init_st_model(os.getenv("EMB_MODEL_NAME", "sentence-transformers/all-MiniLM-L6-v2"))
355
 
356
  def _extract_page(text_chunk: str) -> str:
357
- m = list(re.finditer(r"\\[\\[PAGE=(\\d+)\\]\\]", text_chunk or ""))
 
358
  return (m[-1].group(1) if m else "?")
359
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  def hybrid_search(query: str, k=8, w_tfidf=W_TFIDF_DEFAULT, w_bm25=W_BM25_DEFAULT, w_emb=W_EMB_DEFAULT):
361
  if rag_meta is None or rag_meta.empty:
362
  return pd.DataFrame()
@@ -383,7 +458,7 @@ def hybrid_search(query: str, k=8, w_tfidf=W_TFIDF_DEFAULT, w_bm25=W_BM25_DEFAUL
383
 
384
  # BM25 scores
385
  if bm25 is not None:
386
- q_tokens = [t.lower() for t in re.findall(r"[A-Za-z0-9_#+\\-\\/\\.%%]+", query)]
387
  bm25_scores = np.array(bm25.get_scores(q_tokens), dtype=float)
388
  else:
389
  bm25_scores = np.zeros(len(rag_meta), dtype=float); w_bm25 = 0.0
@@ -415,68 +490,114 @@ def split_sentences(text: str) -> List[str]:
415
  return [s for s in sents if 6 <= len(s.split()) <= 60]
416
 
417
  def mmr_select_sentences(question: str, hits: pd.DataFrame, top_n=4, pool_per_chunk=6, lambda_div=0.7):
 
 
 
 
 
 
 
418
  pool = []
419
  for _, row in hits.iterrows():
420
- doc = Path(row["doc_path"]).name
421
  page = _extract_page(row["text"])
422
- for s in split_sentences(row["text"])[:pool_per_chunk]:
423
- pool.append({"sent": s, "doc": doc, "page": page})
 
 
 
 
424
  if not pool:
425
  return []
426
 
 
427
  sent_texts = [p["sent"] for p in pool]
428
-
429
  use_dense = USE_DENSE and st_query_model is not None
430
- if use_dense:
431
- try:
432
  from sklearn.preprocessing import normalize as sk_normalize
433
- texts = [question] + sent_texts
434
- enc = st_query_model.encode(texts, convert_to_numpy=True)
435
  q_vec = sk_normalize(enc[:1])[0]
436
  S = sk_normalize(enc[1:])
437
  rel = (S @ q_vec)
438
  def sim_fn(i, j): return float(S[i] @ S[j])
439
- except Exception:
440
- use_dense = False
 
 
 
 
 
 
 
 
 
 
441
 
442
- if not use_dense:
443
- from sklearn.feature_extraction.text import TfidfVectorizer
444
- vect = TfidfVectorizer().fit(sent_texts + [question])
445
- Q = vect.transform([question]); S = vect.transform(sent_texts)
446
- rel = (S @ Q.T).toarray().ravel()
447
- def sim_fn(i, j): return float((S[i] @ S[j].T).toarray()[0, 0])
448
 
449
- selected, selected_idx = [], []
450
  remain = list(range(len(pool)))
 
 
451
  first = int(np.argmax(rel))
452
- selected.append(pool[first]); selected_idx.append(first); remain.remove(first)
 
 
453
 
454
- while len(selected) < top_n and remain:
 
 
455
  cand_scores = []
456
  for i in remain:
457
- sim_to_sel = max(sim_fn(i, j) for j in selected_idx) if selected_idx else 0.0
458
- score = lambda_div * rel[i] - (1 - lambda_div) * sim_to_sel
459
  cand_scores.append((score, i))
 
 
460
  cand_scores.sort(reverse=True)
461
- best_i = cand_scores[0][1]
462
- selected.append(pool[best_i]); selected_idx.append(best_i); remain.remove(best_i)
 
 
 
463
  return selected
464
 
465
  def compose_extractive(selected: List[Dict[str, Any]]) -> str:
466
  if not selected:
467
  return ""
468
- return " ".join(f"{s['sent']} ({s['doc']}, p.{s['page']})" for s in selected)
 
469
 
470
- def synthesize_with_llm(question: str, sentence_lines: List[str], model: str = None, temperature: float = 0.2) -> str:
471
- if not LLM_AVAILABLE:
 
 
 
 
 
 
 
 
 
 
 
 
472
  return None
 
 
 
 
 
 
473
  client = OpenAI(api_key=OPENAI_API_KEY)
474
  model = model or OPENAI_MODEL
475
  SYSTEM_PROMPT = (
476
  "You are a scientific assistant for self-sensing cementitious materials.\n"
477
  "Answer STRICTLY using the provided sentences.\n"
478
  "Do not invent facts. Keep it concise (3–6 sentences).\n"
479
- "Retain inline citations like (Doc.pdf, p.X) exactly as given."
480
  )
481
  user_prompt = (
482
  f"Question: {question}\n\n"
@@ -492,9 +613,19 @@ def synthesize_with_llm(question: str, sentence_lines: List[str], model: str = N
492
  ],
493
  temperature=temperature,
494
  )
495
- return getattr(resp, "output_text", None) or str(resp)
 
 
 
 
 
 
 
 
 
 
496
  except Exception:
497
- return None
498
 
499
  def rag_reply(
500
  question: str,
@@ -509,42 +640,159 @@ def rag_reply(
509
  w_bm25: float = W_BM25_DEFAULT,
510
  w_emb: float = W_EMB_DEFAULT
511
  ) -> str:
 
 
 
 
 
512
  hits = hybrid_search(question, k=k, w_tfidf=w_tfidf, w_bm25=w_bm25, w_emb=w_emb)
513
- if hits is None or hits.empty:
514
- return "No indexed PDFs found. Upload PDFs to the 'papers/' folder and reload the Space."
515
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
516
  selected = mmr_select_sentences(question, hits, top_n=int(n_sentences), pool_per_chunk=6, lambda_div=0.7)
517
- header_cites = "; ".join(f"{Path(r['doc_path']).name} (p.{_extract_page(r['text'])})" for _, r in hits.head(6).iterrows())
518
- srcs = {Path(r['doc_path']).name for _, r in hits.iterrows()}
519
- 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."
520
 
521
- # Hidden policy: if strict==True no paraphrasing; else try LLM if available
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  if strict_quotes_only:
523
  if not selected:
524
- return f"**Quoted Passages:**\n\n---\n" + "\n\n".join(hits['text'].tolist()[:2]) + f"\n\n**Citations:** {header_cites}{coverage_note}"
525
- msg = "**Quoted Passages:**\n- " + "\n- ".join(f"{s['sent']} ({s['doc']}, p.{s['page']})" for s in selected)
526
- msg += f"\n\n**Citations:** {header_cites}{coverage_note}"
527
- if include_passages:
528
- msg += "\n\n---\n" + "\n\n".join(hits['text'].tolist()[:2])
529
- return msg
530
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531
  extractive = compose_extractive(selected)
 
 
532
  if use_llm and selected:
533
- lines = [f"{s['sent']} ({s['doc']}, p.{s['page']})" for s in selected]
534
- llm_text = synthesize_with_llm(question, lines, model=model, temperature=temperature)
 
 
 
 
 
535
  if llm_text:
536
- msg = f"**Answer (LLM synthesis):** {llm_text}\n\n**Citations:** {header_cites}{coverage_note}"
537
  if include_passages:
538
- msg += "\n\n---\n" + "\n\n".join(hits['text'].tolist()[:2])
539
- return msg
540
-
541
- if not extractive:
542
- return f"**Answer:** Here are relevant passages.\n\n**Citations:** {header_cites}{coverage_note}\n\n---\n" + "\n\n".join(hits['text'].tolist()[:2])
543
-
544
- msg = f"**Answer:** {extractive}\n\n**Citations:** {header_cites}{coverage_note}"
545
- if include_passages:
546
- msg += "\n\n---\n" + "\n\n".join(hits['text'].tolist()[:2])
547
- return msg
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
548
 
549
  def rag_chat_fn(message, history, top_k, n_sentences, include_passages,
550
  use_llm, model_name, temperature, strict_quotes_only,
@@ -584,7 +832,7 @@ input[type="checkbox"], .gr-checkbox, .gr-checkbox > * { pointer-events: auto !i
584
  .gr-checkbox label, .gr-check-radio label { pointer-events: auto !important; cursor: pointer; }
585
  #rag-tab input[type="checkbox"] { accent-color: #60a5fa !important; }
586
 
587
- /* RAG tab background and elements */
588
  #rag-tab .block, #rag-tab .group, #rag-tab .accordion {
589
  background: linear-gradient(160deg, #1f2937 0%, #14532d 55%, #0b3b68 100%) !important;
590
  border-radius: 12px;
@@ -611,8 +859,173 @@ input[type="checkbox"], .gr-checkbox, .gr-checkbox > * { pointer-events: auto !i
611
  color: #eef6ff !important;
612
  }
613
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
614
  /* Predictor output emphasis */
615
  #pred-out .wrap { font-size: 20px; font-weight: 700; color: #ecfdf5; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
616
  """
617
 
618
  theme = gr.themes.Soft(
@@ -630,11 +1043,37 @@ theme = gr.themes.Soft(
630
  )
631
 
632
  with gr.Blocks(css=CSS, theme=theme, fill_height=True) as demo:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
633
  gr.Markdown(
634
  "<h1 style='margin:0'>Self-Sensing Concrete Assistant</h1>"
635
  "<p style='opacity:.9'>"
636
  "Left: ML prediction for Stress Gauge Factor (original scale, MPa<sup>-1</sup>). "
637
- "Right: Literature Q&A via Hybrid RAG (BM25 + TF-IDF + optional dense) with MMR sentence selection."
 
638
  "</p>"
639
  )
640
 
@@ -644,27 +1083,27 @@ with gr.Blocks(css=CSS, theme=theme, fill_height=True) as demo:
644
  with gr.Row():
645
  with gr.Column(scale=7):
646
  with gr.Accordion("Primary conductive filler", open=True, elem_classes=["card"]):
647
- f1_type = gr.Textbox(label="Filler 1 Type", placeholder="e.g., CNT, Graphite, Steel fiber")
648
- f1_diam = gr.Number(label="Filler 1 Diameter (µm)")
649
- f1_len = gr.Number(label="Filler 1 Length (mm)")
650
- cf_conc = gr.Number(label=f"{CF_COL}", info="Weight percent of total binder")
651
- f1_dim = gr.Dropdown(DIM_CHOICES, value="NA", label="Filler 1 Dimensionality")
652
 
653
  with gr.Accordion("Secondary filler (optional)", open=False, elem_classes=["card"]):
654
  f2_type = gr.Textbox(label="Filler 2 Type", placeholder="Optional")
655
  f2_diam = gr.Number(label="Filler 2 Diameter (µm)")
656
  f2_len = gr.Number(label="Filler 2 Length (mm)")
657
- f2_dim = gr.Dropdown(DIM_CHOICES, value="NA", label="Filler 2 Dimensionality")
658
 
659
  with gr.Accordion("Mix design & specimen", open=False, elem_classes=["card"]):
660
- spec_vol = gr.Number(label="Specimen Volume (mm3)")
661
- probe_cnt = gr.Number(label="Probe Count")
662
- probe_mat = gr.Textbox(label="Probe Material", placeholder="e.g., Copper, Silver paste")
663
- wb = gr.Number(label="W/B")
664
- sb = gr.Number(label="S/B")
665
- gauge_len = gr.Number(label="Gauge Length (mm)")
666
- curing = gr.Textbox(label="Curing Condition", placeholder="e.g., 28d water, 20°C")
667
- n_fillers = gr.Number(label="Number of Fillers")
668
 
669
  with gr.Accordion("Processing", open=False, elem_classes=["card"]):
670
  dry_temp = gr.Number(label="Drying Temperature (°C)")
@@ -672,13 +1111,14 @@ with gr.Blocks(css=CSS, theme=theme, fill_height=True) as demo:
672
 
673
  with gr.Accordion("Mechanical & electrical loading", open=False, elem_classes=["card"]):
674
  load_rate = gr.Number(label="Loading Rate (MPa/s)")
675
- E_mod = gr.Number(label="Modulus of Elasticity (GPa)")
676
- current = gr.Dropdown(CURRENT_CHOICES, value="NA", label="Current Type")
677
  voltage = gr.Number(label="Applied Voltage (V)")
678
 
679
  with gr.Column(scale=5):
680
  with gr.Group(elem_classes=["card"]):
681
  out_pred = gr.Number(label="Predicted Stress GF (MPa-1)", value=0.0, precision=6, elem_id="pred-out")
 
682
  with gr.Row():
683
  btn_pred = gr.Button("Predict", variant="primary")
684
  btn_clear = gr.Button("Clear")
@@ -687,7 +1127,7 @@ with gr.Blocks(css=CSS, theme=theme, fill_height=True) as demo:
687
  with gr.Accordion("About this model", open=False, elem_classes=["card"]):
688
  gr.Markdown(
689
  "- Pipeline: ColumnTransformer → (RobustScaler + OneHot) → XGBoost\n"
690
- "- Target: Stress GF (MPa<sup>-1</sup>) on original scale (model trains on log1p).\n"
691
  "- Missing values are safely imputed per-feature.\n"
692
  "- Trained columns:\n"
693
  f" `{', '.join(MAIN_VARIABLES)}`",
@@ -713,9 +1153,11 @@ with gr.Blocks(css=CSS, theme=theme, fill_height=True) as demo:
713
 
714
  # ------------------------- Literature Tab -------------------------
715
  with gr.Tab("📚 Ask the Literature (Hybrid RAG + MMR)", elem_id="rag-tab"):
 
716
  gr.Markdown(
717
- "Upload PDFs into the repository folder <code>papers/</code> then reload the Space. "
718
- "Answers cite (Doc.pdf, p.X)."
 
719
  )
720
  with gr.Row():
721
  top_k = gr.Slider(5, 12, value=8, step=1, label="Top-K chunks")
@@ -727,11 +1169,11 @@ with gr.Blocks(css=CSS, theme=theme, fill_height=True) as demo:
727
  w_bm25 = gr.Slider(0.0, 1.0, value=W_BM25_DEFAULT, step=0.05, label="BM25 weight")
728
  w_emb = gr.Slider(0.0, 1.0, value=(0.0 if not USE_DENSE else 0.40), step=0.05, label="Dense weight (set 0 if disabled)")
729
 
730
- # ---- Hidden states for LLM behavior (no visible controls) ----
731
- state_use_llm = gr.State(LLM_AVAILABLE) # True when key present; else False
732
  state_model_name = gr.State(os.getenv("OPENAI_MODEL", OPENAI_MODEL))
733
  state_temperature = gr.State(0.2)
734
- state_strict = gr.State(False) # hidden: default to not-strict
735
 
736
  gr.ChatInterface(
737
  fn=rag_chat_fn,
@@ -741,9 +1183,73 @@ with gr.Blocks(css=CSS, theme=theme, fill_height=True) as demo:
741
  w_tfidf, w_bm25, w_emb
742
  ],
743
  title="Literature Q&A",
744
- description="Hybrid retrieval with diversity. Answers carry inline (Doc, p.X) citations."
745
  )
746
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
747
  # ------------- Launch -------------
748
  if __name__ == "__main__":
749
  demo.queue().launch()
 
 
 
 
 
 
 
 
 
1
  # ================================================================
2
  # Self-Sensing Concrete Assistant — Predictor (XGB) + Hybrid RAG
3
+ # - Uses local 'papers/' folder for literature
4
+ # - Robust MMR sentence selection (no list index errors)
5
+ # - Predictor: safe model caching + safe feature alignment
6
+ # - Stable categoricals ("NA"); no over-strict completeness gate
7
+ # - Lightweight instrumentation (JSONL logs per RAG turn)
8
+ # - Dark-blue theme + Evaluate tab + k-slider styling
9
+ # - Citations use SHORT CODES (e.g., S71, S92) from filenames
10
  # ================================================================
11
 
12
  # ---------------------- Runtime flags (HF-safe) ----------------------
 
16
  os.environ["TOKENIZERS_PARALLELISM"] = "false"
17
 
18
  # ------------------------------- Imports ------------------------------
19
+ import re, joblib, warnings, json, traceback, time, uuid, subprocess, sys
20
  from pathlib import Path
21
+ from typing import List, Dict, Any, Optional
22
 
23
  import numpy as np
24
  import pandas as pd
 
39
  BM25Okapi = None
40
  print("rank_bm25 not installed; BM25 disabled (TF-IDF still works).")
41
 
42
+ # Optional OpenAI (for LLM synthesis)
43
  OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
44
  OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-5")
45
  try:
 
53
  # ========================= Predictor (kept) =========================
54
  CF_COL = "Conductive Filler Conc. (wt%)"
55
  TARGET_COL = "Stress GF (MPa-1)"
56
+ CANON_NA = "NA" # canonical placeholder for categoricals
57
 
58
  MAIN_VARIABLES = [
59
  "Filler 1 Type",
 
110
  "Current Type"
111
  }
112
 
113
+ DIM_CHOICES = ["0D", "1D", "2D", "3D", CANON_NA]
114
+ CURRENT_CHOICES = ["DC", "AC", CANON_NA]
 
 
 
 
 
 
 
115
 
116
  MODEL_CANDIDATES = [
117
  "stress_gf_xgb.joblib",
118
  "models/stress_gf_xgb.joblib",
119
  "/home/user/app/stress_gf_xgb.joblib",
120
+ os.getenv("MODEL_PATH", "")
121
  ]
122
 
123
+ # ---------- Model caching + status ----------
124
+ MODEL = None
125
+ MODEL_STATUS = "🔴 Model not loaded"
126
+
127
+ def _try_load_model():
128
+ global MODEL, MODEL_STATUS
129
+ for p in [x for x in MODEL_CANDIDATES if x]:
130
  if os.path.exists(p):
131
  try:
132
+ MODEL = joblib.load(p)
133
+ MODEL_STATUS = f"🟢 Loaded model: {Path(p).name}"
134
+ print("[ModelLoad] Loaded:", p)
135
+ return
136
  except Exception as e:
137
+ print(f"[ModelLoad] Error from {p}: {e}")
138
+ traceback.print_exc()
139
+ MODEL = None
140
+ if MODEL is None:
141
+ MODEL_STATUS = "🔴 Model not found (place stress_gf_xgb.joblib at repo root or models/, or set MODEL_PATH)"
142
+ print("[ModelLoad]", MODEL_STATUS)
143
+
144
+ _try_load_model() # load at import time
145
+
146
+ def _canon_cat(v: Any) -> str:
147
+ """Stable, canonical category placeholder normalization."""
148
+ if v is None:
149
+ return CANON_NA
150
+ s = str(v).strip()
151
+ if s == "" or s.upper() in {"N/A", "NONE", "NULL"}:
152
+ return CANON_NA
153
+ return s
154
+
155
+ def _to_float_or_nan(v):
156
+ if v in ("", None):
157
+ return np.nan
158
+ try:
159
+ return float(str(v).replace(",", ""))
160
+ except Exception:
161
+ return np.nan
162
 
163
  def _coerce_to_row(form_dict: dict) -> pd.DataFrame:
164
  row = {}
165
  for col in MAIN_VARIABLES:
166
  v = form_dict.get(col, None)
167
  if col in NUMERIC_COLS:
168
+ row[col] = _to_float_or_nan(v)
169
+ elif col in CATEGORICAL_COLS:
170
+ row[col] = _canon_cat(v)
 
 
 
 
171
  else:
172
+ s = str(v).strip() if v is not None else ""
173
+ row[col] = s if s else CANON_NA
174
  return pd.DataFrame([row], columns=MAIN_VARIABLES)
175
 
176
+ def _align_columns_to_model(df: pd.DataFrame, mdl) -> pd.DataFrame:
177
+ """
178
+ SAFE alignment:
179
+ - If mdl.feature_names_in_ exists AND is a subset of df.columns (raw names), reorder to it.
180
+ - Else, try a Pipeline step (e.g., 'preprocessor') with feature_names_in_ subset of df.columns.
181
+ - Else, DO NOT align (let the pipeline handle columns by name).
182
+ """
183
+ try:
184
+ feat = getattr(mdl, "feature_names_in_", None)
185
+ if isinstance(feat, (list, np.ndarray, pd.Index)):
186
+ feat = list(feat)
187
+ if all(c in df.columns for c in feat):
188
+ return df[feat]
189
+
190
+ if hasattr(mdl, "named_steps"):
191
+ for key in ["preprocessor", "columntransformer"]:
192
+ if key in mdl.named_steps:
193
+ step = mdl.named_steps[key]
194
+ feat2 = getattr(step, "feature_names_in_", None)
195
+ if isinstance(feat2, (list, np.ndarray, pd.Index)):
196
+ feat2 = list(feat2)
197
+ if all(c in df.columns for c in feat2):
198
+ return df[feat2]
199
+ # fallback to first step if it exposes input names
200
  try:
201
+ first_key = list(mdl.named_steps.keys())[0]
202
+ step = mdl.named_steps[first_key]
203
+ feat3 = getattr(step, "feature_names_in_", None)
204
+ if isinstance(feat3, (list, np.ndarray, pd.Index)):
205
+ feat3 = list(feat3)
206
+ if all(c in df.columns for c in feat3):
207
+ return df[feat3]
208
  except Exception:
209
+ pass
210
+
211
+ return df
212
+ except Exception as e:
213
+ print(f"[Align] Skip aligning due to: {e}")
214
+ traceback.print_exc()
215
+ return df
 
 
 
216
 
217
  def predict_fn(**kwargs):
218
+ """
219
+ Always attempt prediction.
220
+ - Missing numerics -> NaN (imputer handles)
221
+ - Categoricals -> 'NA'
222
+ - If model missing or inference error -> 0.0 (keeps UI stable)
223
+ """
224
+ if MODEL is None:
225
  return 0.0
 
 
 
226
  X_new = _coerce_to_row(kwargs)
227
+ X_new = _align_columns_to_model(X_new, MODEL)
228
  try:
229
+ y_raw = MODEL.predict(X_new) # log1p or original scale depending on training
230
+ if getattr(MODEL, "target_is_log1p_", False):
231
+ y = np.expm1(y_raw)
232
+ else:
233
+ y = y_raw
234
+ y = float(np.asarray(y).ravel()[0])
235
+ return max(y, 0.0)
236
  except Exception as e:
237
+ print(f"[Predict] {e}")
238
+ traceback.print_exc()
239
+ return 0.0
240
 
241
  EXAMPLE = {
242
  "Filler 1 Type": "CNT",
 
245
  "Filler 1 Length (mm)": 1.2,
246
  CF_COL: 0.5,
247
  "Filler 2 Type": "",
248
+ "Filler 2 Dimensionality": CANON_NA,
249
  "Filler 2 Diameter (µm)": None,
250
  "Filler 2 Length (mm)": None,
251
  "Specimen Volume (mm3)": 1000,
 
273
  if col in NUMERIC_COLS:
274
  cleared.append(None)
275
  elif col in {"Filler 1 Dimensionality", "Filler 2 Dimensionality"}:
276
+ cleared.append(CANON_NA)
277
  elif col == "Current Type":
278
+ cleared.append(CANON_NA)
279
  else:
280
  cleared.append("")
281
  return cleared
 
345
  return None
346
 
347
  def build_or_load_hybrid(pdf_dir: Path):
348
+ # Build or load the hybrid retriever cache
349
  have_cache = (TFIDF_VECT_PATH.exists() and TFIDF_MAT_PATH.exists()
350
  and RAG_META_PATH.exists()
351
  and (BM25_TOK_PATH.exists() or BM25Okapi is None)
 
398
  emb = None
399
 
400
  joblib.dump(vectorizer, TFIDF_VECT_PATH)
401
+ joblib.dump(X_tfidf, TFIDF_MAT_PATH)
402
  if BM25Okapi is not None:
403
  joblib.dump(all_tokens, BM25_TOK_PATH)
404
  meta.to_parquet(RAG_META_PATH, index=False)
 
409
  st_query_model = _safe_init_st_model(os.getenv("EMB_MODEL_NAME", "sentence-transformers/all-MiniLM-L6-v2"))
410
 
411
  def _extract_page(text_chunk: str) -> str:
412
+ # Correct: [[PAGE=123]]
413
+ m = list(re.finditer(r"\[\[PAGE=(\d+)\]\]", text_chunk or ""))
414
  return (m[-1].group(1) if m else "?")
415
 
416
+ def _short_doc_code(doc_path: str) -> str:
417
+ """
418
+ Turn a full filename like:
419
+ 'S92-Research-on-the-self-sensing-and-mechanical-properties-of_2021_Cement-and-Co.pdf'
420
+ into a short code:
421
+ 'S92'
422
+ For generic names, falls back to the first token of the stem.
423
+ """
424
+ if not doc_path:
425
+ return "Source"
426
+ name = Path(doc_path).name
427
+ stem = name.rsplit(".", 1)[0]
428
+ # Split on whitespace, hyphen, underscore
429
+ parts = re.split(r"[ \t\n\r\-_]+", stem)
430
+ for p in parts:
431
+ if p:
432
+ return p
433
+ return stem or "Source"
434
+
435
  def hybrid_search(query: str, k=8, w_tfidf=W_TFIDF_DEFAULT, w_bm25=W_BM25_DEFAULT, w_emb=W_EMB_DEFAULT):
436
  if rag_meta is None or rag_meta.empty:
437
  return pd.DataFrame()
 
458
 
459
  # BM25 scores
460
  if bm25 is not None:
461
+ q_tokens = [t.lower() for t in re.findall(r"[A-Za-z0-9_#+\-\/\.%]+", query)]
462
  bm25_scores = np.array(bm25.get_scores(q_tokens), dtype=float)
463
  else:
464
  bm25_scores = np.zeros(len(rag_meta), dtype=float); w_bm25 = 0.0
 
490
  return [s for s in sents if 6 <= len(s.split()) <= 60]
491
 
492
  def mmr_select_sentences(question: str, hits: pd.DataFrame, top_n=4, pool_per_chunk=6, lambda_div=0.7):
493
+ """
494
+ Robust MMR sentence picker:
495
+ - Handles empty pools
496
+ - Clamps top_n to pool size
497
+ - Avoids 'list index out of range'
498
+ """
499
+ # Build pool
500
  pool = []
501
  for _, row in hits.iterrows():
502
+ doc_code = _short_doc_code(row["doc_path"])
503
  page = _extract_page(row["text"])
504
+ sents = split_sentences(row["text"])
505
+ if not sents:
506
+ continue
507
+ for s in sents[:max(1, int(pool_per_chunk))]:
508
+ pool.append({"sent": s, "doc": doc_code, "page": page})
509
+
510
  if not pool:
511
  return []
512
 
513
+ # Relevance vectors
514
  sent_texts = [p["sent"] for p in pool]
 
515
  use_dense = USE_DENSE and st_query_model is not None
516
+ try:
517
+ if use_dense:
518
  from sklearn.preprocessing import normalize as sk_normalize
519
+ enc = st_query_model.encode([question] + sent_texts, convert_to_numpy=True)
 
520
  q_vec = sk_normalize(enc[:1])[0]
521
  S = sk_normalize(enc[1:])
522
  rel = (S @ q_vec)
523
  def sim_fn(i, j): return float(S[i] @ S[j])
524
+ else:
525
+ from sklearn.feature_extraction.text import TfidfVectorizer
526
+ vect = TfidfVectorizer().fit(sent_texts + [question])
527
+ Q = vect.transform([question]); S = vect.transform(sent_texts)
528
+ rel = (S @ Q.T).toarray().ravel()
529
+ def sim_fn(i, j):
530
+ num = (S[i] @ S[j].T)
531
+ return float(num.toarray()[0, 0]) if hasattr(num, "toarray") else float(num)
532
+ except Exception:
533
+ # Fallback: uniform relevance if vectorization fails
534
+ rel = np.ones(len(sent_texts), dtype=float)
535
+ def sim_fn(i, j): return 0.0
536
 
537
+ # Normalize lambda_div
538
+ lambda_div = float(np.clip(lambda_div, 0.0, 1.0))
 
 
 
 
539
 
540
+ # Select first by highest relevance
541
  remain = list(range(len(pool)))
542
+ if not remain:
543
+ return []
544
  first = int(np.argmax(rel))
545
+ selected_idx = [first]
546
+ selected = [pool[first]]
547
+ remain.remove(first)
548
 
549
+ # Clamp top_n
550
+ max_pick = min(int(top_n), len(pool))
551
+ while len(selected) < max_pick and remain:
552
  cand_scores = []
553
  for i in remain:
554
+ div_i = max(sim_fn(i, j) for j in selected_idx) if selected_idx else 0.0
555
+ score = lambda_div * float(rel[i]) - (1.0 - lambda_div) * div_i
556
  cand_scores.append((score, i))
557
+ if not cand_scores:
558
+ break
559
  cand_scores.sort(reverse=True)
560
+ _, best_i = cand_scores[0]
561
+ selected_idx.append(best_i)
562
+ selected.append(pool[best_i])
563
+ remain.remove(best_i)
564
+
565
  return selected
566
 
567
  def compose_extractive(selected: List[Dict[str, Any]]) -> str:
568
  if not selected:
569
  return ""
570
+ # Citations inside answer are short codes only, e.g. (S92), (S71)
571
+ return " ".join(f"{s['sent']} ({s['doc']})" for s in selected)
572
 
573
+ # ========================= NEW: Instrumentation helpers =========================
574
+ LOG_PATH = ARTIFACT_DIR / "rag_logs.jsonl"
575
+ OPENAI_IN_COST_PER_1K = float(os.getenv("OPENAI_COST_IN_PER_1K", "0"))
576
+ OPENAI_OUT_COST_PER_1K = float(os.getenv("OPENAI_COST_OUT_PER_1K", "0"))
577
+
578
+ def _safe_write_jsonl(path: Path, record: dict):
579
+ try:
580
+ with open(path, "a", encoding="utf-8") as f:
581
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
582
+ except Exception as e:
583
+ print("[Log] write failed:", e)
584
+
585
+ def _calc_cost_usd(prompt_toks, completion_toks):
586
+ if prompt_toks is None or completion_toks is None:
587
  return None
588
+ return (prompt_toks / 1000.0) * OPENAI_IN_COST_PER_1K + (completion_toks / 1000.0) * OPENAI_OUT_COST_PER_1K
589
+
590
+ # ----------------- Modified to return (text, usage_dict) -----------------
591
+ def synthesize_with_llm(question: str, sentence_lines: List[str], model: str = None, temperature: float = 0.2):
592
+ if not LLM_AVAILABLE:
593
+ return None, None
594
  client = OpenAI(api_key=OPENAI_API_KEY)
595
  model = model or OPENAI_MODEL
596
  SYSTEM_PROMPT = (
597
  "You are a scientific assistant for self-sensing cementitious materials.\n"
598
  "Answer STRICTLY using the provided sentences.\n"
599
  "Do not invent facts. Keep it concise (3–6 sentences).\n"
600
+ "Retain inline citations exactly as given (e.g., (S92), (S92; S71))."
601
  )
602
  user_prompt = (
603
  f"Question: {question}\n\n"
 
613
  ],
614
  temperature=temperature,
615
  )
616
+ out_text = getattr(resp, "output_text", None) or str(resp)
617
+ usage = None
618
+ try:
619
+ u = getattr(resp, "usage", None)
620
+ if u:
621
+ pt = getattr(u, "prompt_tokens", None) if hasattr(u, "prompt_tokens") else u.get("prompt_tokens", None)
622
+ ct = getattr(u, "completion_tokens", None) if hasattr(u, "completion_tokens") else u.get("completion_tokens", None)
623
+ usage = {"prompt_tokens": pt, "completion_tokens": ct}
624
+ except Exception:
625
+ usage = None
626
+ return out_text, usage
627
  except Exception:
628
+ return None, None
629
 
630
  def rag_reply(
631
  question: str,
 
640
  w_bm25: float = W_BM25_DEFAULT,
641
  w_emb: float = W_EMB_DEFAULT
642
  ) -> str:
643
+ run_id = str(uuid.uuid4())
644
+ t0_total = time.time()
645
+ t0_retr = time.time()
646
+
647
+ # --- Retrieval ---
648
  hits = hybrid_search(question, k=k, w_tfidf=w_tfidf, w_bm25=w_bm25, w_emb=w_emb)
649
+ t1_retr = time.time()
650
+ latency_ms_retriever = int((t1_retr - t0_retr) * 1000)
651
 
652
+ if hits is None or hits.empty:
653
+ final = "No indexed PDFs found. Upload PDFs to the 'papers/' folder and reload the Space."
654
+ record = {
655
+ "run_id": run_id,
656
+ "ts": int(time.time()*1000),
657
+ "inputs": {
658
+ "question": question, "top_k": int(k), "n_sentences": int(n_sentences),
659
+ "w_tfidf": float(w_tfidf), "w_bm25": float(w_bm25), "w_emb": float(w_emb),
660
+ "use_llm": bool(use_llm), "model": model, "temperature": float(temperature)
661
+ },
662
+ "retrieval": {"hits": [], "latency_ms_retriever": latency_ms_retriever},
663
+ "output": {"final_answer": final, "used_sentences": []},
664
+ "latency_ms_total": int((time.time()-t0_total)*1000),
665
+ "openai": None
666
+ }
667
+ _safe_write_jsonl(LOG_PATH, record)
668
+ return final
669
+
670
+ # Select sentences
671
  selected = mmr_select_sentences(question, hits, top_n=int(n_sentences), pool_per_chunk=6, lambda_div=0.7)
 
 
 
672
 
673
+ # Header citations: short codes only, joined by '; ' (e.g., "S55; S71; S92")
674
+ header_codes = []
675
+ for _, r in hits.head(6).iterrows():
676
+ code = _short_doc_code(r["doc_path"])
677
+ if code not in header_codes:
678
+ header_codes.append(code)
679
+ header_cites = "; ".join(header_codes)
680
+ src_codes = set(header_codes)
681
+ coverage_note = "" if len(src_codes) >= 3 else f"\n\n> Note: Only {len(src_codes)} unique source(s) contributed. Add more PDFs or increase Top-K."
682
+
683
+ # Prepare retrieval list for logging (full filenames kept here)
684
+ retr_list = []
685
+ for _, r in hits.iterrows():
686
+ retr_list.append({
687
+ "doc": Path(r["doc_path"]).name,
688
+ "page": _extract_page(r["text"]),
689
+ "score_tfidf": float(r.get("score_tfidf", 0.0)),
690
+ "score_bm25": float(r.get("score_bm25", 0.0)),
691
+ "score_dense": float(r.get("score_dense", 0.0)),
692
+ "combo_score": float(r.get("score", 0.0)),
693
+ })
694
+
695
+ # Strict quotes only (no LLM)
696
  if strict_quotes_only:
697
  if not selected:
698
+ final = (
699
+ "**Quoted Passages:**\n\n---\n" +
700
+ "\n\n".join(hits['text'].tolist()[:2]) +
701
+ f"\n\n**Citations:** {header_cites}{coverage_note}"
702
+ )
703
+ else:
704
+ bullets = "\n- ".join(f"{s['sent']} ({s['doc']})" for s in selected)
705
+ final = f"**Quoted Passages:**\n- {bullets}\n\n**Citations:** {header_cites}{coverage_note}"
706
+ if include_passages:
707
+ final += "\n\n---\n" + "\n\n".join(hits['text'].tolist()[:2])
708
+
709
+ record = {
710
+ "run_id": run_id,
711
+ "ts": int(time.time()*1000),
712
+ "inputs": {
713
+ "question": question, "top_k": int(k), "n_sentences": int(n_sentences),
714
+ "w_tfidf": float(w_tfidf), "w_bm25": float(w_bm25), "w_emb": float(w_emb),
715
+ "use_llm": False, "model": None, "temperature": float(temperature)
716
+ },
717
+ "retrieval": {"hits": retr_list, "latency_ms_retriever": latency_ms_retriever},
718
+ "output": {
719
+ "final_answer": final,
720
+ "used_sentences": [{"sent": s["sent"], "doc": s["doc"], "page": s["page"]} for s in selected]
721
+ },
722
+ "latency_ms_total": int((time.time()-t0_total)*1000),
723
+ "openai": None
724
+ }
725
+ _safe_write_jsonl(LOG_PATH, record)
726
+ return final
727
+
728
+ # Extractive or LLM synthesis
729
  extractive = compose_extractive(selected)
730
+ llm_usage = None
731
+ llm_latency_ms = None
732
  if use_llm and selected:
733
+ # Lines already carry short-code citations, e.g. "... (S92)"
734
+ lines = [f"{s['sent']} ({s['doc']})" for s in selected]
735
+ t0_llm = time.time()
736
+ llm_text, llm_usage = synthesize_with_llm(question, lines, model=model, temperature=temperature)
737
+ t1_llm = time.time()
738
+ llm_latency_ms = int((t1_llm - t0_llm) * 1000)
739
+
740
  if llm_text:
741
+ final = f"**Answer (LLM synthesis):** {llm_text}\n\n**Citations:** {header_cites}{coverage_note}"
742
  if include_passages:
743
+ final += "\n\n---\n" + "\n\n".join(hits['text'].tolist()[:2])
744
+ else:
745
+ if not extractive:
746
+ final = (
747
+ f"**Answer:** Here are relevant passages.\n\n"
748
+ f"**Citations:** {header_cites}{coverage_note}\n\n---\n" +
749
+ "\n\n".join(hits['text'].tolist()[:2])
750
+ )
751
+ else:
752
+ final = f"**Answer:** {extractive}\n\n**Citations:** {header_cites}{coverage_note}"
753
+ if include_passages:
754
+ final += "\n\n---\n" + "\n\n".join(hits['text'].tolist()[:2])
755
+ else:
756
+ if not extractive:
757
+ final = (
758
+ f"**Answer:** Here are relevant passages.\n\n"
759
+ f"**Citations:** {header_cites}{coverage_note}\n\n---\n" +
760
+ "\n\n".join(hits['text'].tolist()[:2])
761
+ )
762
+ else:
763
+ final = f"**Answer:** {extractive}\n\n**Citations:** {header_cites}{coverage_note}"
764
+ if include_passages:
765
+ final += "\n\n---\n" + "\n\n".join(hits['text'].tolist()[:2])
766
+
767
+ # --------- Log full run ---------
768
+ prompt_toks = llm_usage.get("prompt_tokens") if llm_usage else None
769
+ completion_toks = llm_usage.get("completion_tokens") if llm_usage else None
770
+ cost_usd = _calc_cost_usd(prompt_toks, completion_toks)
771
+
772
+ total_ms = int((time.time() - t0_total) * 1000)
773
+ record = {
774
+ "run_id": run_id,
775
+ "ts": int(time.time()*1000),
776
+ "inputs": {
777
+ "question": question, "top_k": int(k), "n_sentences": int(n_sentences),
778
+ "w_tfidf": float(w_tfidf), "w_bm25": float(w_bm25), "w_emb": float(w_emb),
779
+ "use_llm": bool(use_llm), "model": model, "temperature": float(temperature)
780
+ },
781
+ "retrieval": {"hits": retr_list, "latency_ms_retriever": latency_ms_retriever},
782
+ "output": {
783
+ "final_answer": final,
784
+ "used_sentences": [{"sent": s['sent'], "doc": s['doc'], "page": s['page']} for s in selected]
785
+ },
786
+ "latency_ms_total": total_ms,
787
+ "latency_ms_llm": llm_latency_ms,
788
+ "openai": {
789
+ "prompt_tokens": prompt_toks,
790
+ "completion_tokens": completion_toks,
791
+ "cost_usd": cost_usd
792
+ } if use_llm else None
793
+ }
794
+ _safe_write_jsonl(LOG_PATH, record)
795
+ return final
796
 
797
  def rag_chat_fn(message, history, top_k, n_sentences, include_passages,
798
  use_llm, model_name, temperature, strict_quotes_only,
 
832
  .gr-checkbox label, .gr-check-radio label { pointer-events: auto !important; cursor: pointer; }
833
  #rag-tab input[type="checkbox"] { accent-color: #60a5fa !important; }
834
 
835
+ /* RAG tab styling */
836
  #rag-tab .block, #rag-tab .group, #rag-tab .accordion {
837
  background: linear-gradient(160deg, #1f2937 0%, #14532d 55%, #0b3b68 100%) !important;
838
  border-radius: 12px;
 
859
  color: #eef6ff !important;
860
  }
861
 
862
+ /* Evaluate tab dark/high-contrast styling */
863
+ #eval-tab .block, #eval-tab .group, #eval-tab .accordion {
864
+ background: linear-gradient(165deg, #0a0f1f 0%, #0d1a31 60%, #0a1c2e 100%) !important;
865
+ border-radius: 12px;
866
+ border: 1px solid rgba(139, 197, 255, 0.28);
867
+ }
868
+ #eval-tab label, #eval-tab .markdown, #eval-tab .prose, #eval-tab p, #eval-tab span {
869
+ color: #e6f2ff !important;
870
+ }
871
+ #eval-tab input, #eval-tab .gr-file, #eval-tab .scroll-hide, #eval-tab textarea, #eval-tab select {
872
+ background: rgba(8, 13, 26, 0.9) !important;
873
+ border: 1px solid #3b82f6 !important;
874
+ color: #dbeafe !important;
875
+ }
876
+ #eval-tab input[type="range"] { accent-color: #22c55e !important; }
877
+ #eval-tab button {
878
+ border-radius: 10px !important;
879
+ font-weight: 700 !important;
880
+ background: #0ea5e9 !important;
881
+ color: #001321 !important;
882
+ border: 1px solid #7dd3fc !important;
883
+ }
884
+ #eval-tab .gr-json, #eval-tab .markdown pre, #eval-tab .markdown code {
885
+ background: rgba(2, 6, 23, 0.85) !important;
886
+ color: #e2e8f0 !important;
887
+ border: 1px solid rgba(148, 163, 184, 0.3) !important;
888
+ border-radius: 10px !important;
889
+ }
890
+
891
  /* Predictor output emphasis */
892
  #pred-out .wrap { font-size: 20px; font-weight: 700; color: #ecfdf5; }
893
+
894
+ /* Tab header: darker blue theme for all tabs */
895
+ .gradio-container .tab-nav button[role="tab"] {
896
+ background: #0b1b34 !important;
897
+ color: #cfe6ff !important;
898
+ border: 1px solid #1e3a8a !important;
899
+ }
900
+ .gradio-container .tab-nav button[role="tab"][aria-selected="true"] {
901
+ background: #0e2a57 !important;
902
+ color: #e0f2fe !important;
903
+ border-color: #3b82f6 !important;
904
+ }
905
+
906
+ /* Evaluate tab: enforce dark-blue text for labels/marks */
907
+ #eval-tab .label,
908
+ #eval-tab label,
909
+ #eval-tab .gr-slider .label,
910
+ #eval-tab .wrap .label,
911
+ #eval-tab .prose,
912
+ #eval-tab .markdown,
913
+ #eval-tab p,
914
+ #eval-tab span {
915
+ color: #cfe6ff !important;
916
+ }
917
+
918
+ /* Target the specific k-slider label strongly */
919
+ #k-slider .label,
920
+ #k-slider label,
921
+ #k-slider .wrap .label {
922
+ color: #cfe6ff !important;
923
+ text-shadow: 0 1px 0 rgba(0,0,0,0.35);
924
+ }
925
+
926
+ /* Slider track/thumb (dark blue gradient + blue thumb) */
927
+ #eval-tab input[type="range"] {
928
+ accent-color: #3b82f6 !important;
929
+ }
930
+
931
+ /* WebKit */
932
+ #eval-tab input[type="range"]::-webkit-slider-runnable-track {
933
+ height: 6px;
934
+ background: linear-gradient(90deg, #0b3b68, #1e3a8a);
935
+ border-radius: 4px;
936
+ }
937
+ #eval-tab input[type="range"]::-webkit-slider-thumb {
938
+ -webkit-appearance: none;
939
+ appearance: none;
940
+ margin-top: -6px;
941
+ width: 18px; height: 18px;
942
+ background: #1d4ed8;
943
+ border: 1px solid #60a5fa;
944
+ border-radius: 50%;
945
+ }
946
+
947
+ /* Firefox */
948
+ #eval-tab input[type="range"]::-moz-range-track {
949
+ height: 6px;
950
+ background: linear-gradient(90deg, #0b3b68, #1e3a8a);
951
+ border-radius: 4px;
952
+ }
953
+ #eval-tab input[type="range"]::-moz-range-thumb {
954
+ width: 18px; height: 18px;
955
+ background: #1d4ed8;
956
+ border: 1px solid #60a5fa;
957
+ border-radius: 50%;
958
+ }
959
+
960
+ /* ======== PATCH: Style the File + JSON outputs by ID ======== */
961
+ #perq-file, #agg-file {
962
+ background: rgba(8, 13, 26, 0.9) !important;
963
+ border: 1px solid #3b82f6 !important;
964
+ border-radius: 12px !important;
965
+ padding: 8px !important;
966
+ }
967
+ #perq-file * , #agg-file * { color: #dbeafe !important; }
968
+ #perq-file a, #agg-file a {
969
+ background: #0e2a57 !important;
970
+ color: #e0f2fe !important;
971
+ border: 1px solid #60a5fa !important;
972
+ border-radius: 8px !important;
973
+ padding: 6px 10px !important;
974
+ text-decoration: none !important;
975
+ }
976
+ #perq-file a:hover, #agg-file a:hover {
977
+ background: #10356f !important;
978
+ border-color: #93c5fd !important;
979
+ }
980
+ /* File preview wrappers (covers multiple Gradio render modes) */
981
+ #perq-file .file-preview, #agg-file .file-preview,
982
+ #perq-file .wrap, #agg-file .wrap {
983
+ background: rgba(2, 6, 23, 0.85) !important;
984
+ border-radius: 10px !important;
985
+ border: 1px solid rgba(148,163,184,.3) !important;
986
+ }
987
+
988
+ /* JSON output: dark panel + readable text */
989
+ #agg-json {
990
+ background: rgba(2, 6, 23, 0.85) !important;
991
+ border: 1px solid rgba(148,163,184,.35) !important;
992
+ border-radius: 12px !important;
993
+ padding: 8px !important;
994
+ }
995
+ #agg-json *, #agg-json .json, #agg-json .wrap { color: #e6f2ff !important; }
996
+ #agg-json pre, #agg-json code {
997
+ background: rgba(4, 10, 24, 0.9) !important;
998
+ color: #e2e8f0 !important;
999
+ border: 1px solid rgba(148,163,184,.35) !important;
1000
+ border-radius: 10px !important;
1001
+ }
1002
+ /* Tree/overflow modes */
1003
+ #agg-json [data-testid="json-tree"],
1004
+ #agg-json [role="tree"],
1005
+ #agg-json .overflow-auto {
1006
+ background: rgba(4, 10, 24, 0.9) !important;
1007
+ color: #e6f2ff !important;
1008
+ border-radius: 10px !important;
1009
+ border: 1px solid rgba(148,163,184,.35) !important;
1010
+ }
1011
+
1012
+ /* Eval log markdown */
1013
+ #eval-log, #eval-log * { color: #cfe6ff !important; }
1014
+ #eval-log pre, #eval-log code {
1015
+ background: rgba(2, 6, 23, 0.85) !important;
1016
+ color: #e2e8f0 !important;
1017
+ border: 1px solid rgba(148,163,184,.3) !important;
1018
+ border-radius: 10px !important;
1019
+ }
1020
+
1021
+ /* When Evaluate tab is active and JS has added .eval-active, bump contrast subtly */
1022
+ #eval-tab.eval-active .block,
1023
+ #eval-tab.eval-active .group {
1024
+ border-color: #60a5fa !important;
1025
+ }
1026
+ #eval-tab.eval-active .label {
1027
+ color: #e6f2ff !important;
1028
+ }
1029
  """
1030
 
1031
  theme = gr.themes.Soft(
 
1043
  )
1044
 
1045
  with gr.Blocks(css=CSS, theme=theme, fill_height=True) as demo:
1046
+ # Optional: JS to toggle .eval-active when Evaluate tab selected
1047
+ gr.HTML("""
1048
+ <script>
1049
+ (function(){
1050
+ const applyEvalActive = () => {
1051
+ const selected = document.querySelector('.tab-nav button[role="tab"][aria-selected="true"]');
1052
+ const evalPanel = document.querySelector('#eval-tab');
1053
+ if (!evalPanel) return;
1054
+ if (selected && /Evaluate/.test(selected.textContent)) {
1055
+ evalPanel.classList.add('eval-active');
1056
+ } else {
1057
+ evalPanel.classList.remove('eval-active');
1058
+ }
1059
+ };
1060
+ document.addEventListener('click', function(e) {
1061
+ if (e.target && e.target.getAttribute('role') === 'tab') {
1062
+ setTimeout(applyEvalActive, 50);
1063
+ }
1064
+ }, true);
1065
+ document.addEventListener('DOMContentLoaded', applyEvalActive);
1066
+ setTimeout(applyEvalActive, 300);
1067
+ })();
1068
+ </script>
1069
+ """)
1070
+
1071
  gr.Markdown(
1072
  "<h1 style='margin:0'>Self-Sensing Concrete Assistant</h1>"
1073
  "<p style='opacity:.9'>"
1074
  "Left: ML prediction for Stress Gauge Factor (original scale, MPa<sup>-1</sup>). "
1075
+ "Right: Literature Q&A via Hybrid RAG (BM25 + TF-IDF + optional dense) with MMR sentence selection. "
1076
+ "Answers cite short document codes (e.g., <code>S71</code>, <code>S92</code>)."
1077
  "</p>"
1078
  )
1079
 
 
1083
  with gr.Row():
1084
  with gr.Column(scale=7):
1085
  with gr.Accordion("Primary conductive filler", open=True, elem_classes=["card"]):
1086
+ f1_type = gr.Textbox(label="Filler 1 Type *", placeholder="e.g., CNT, Graphite, Steel fiber")
1087
+ f1_diam = gr.Number(label="Filler 1 Diameter (µm) *")
1088
+ f1_len = gr.Number(label="Filler 1 Length (mm) *")
1089
+ cf_conc = gr.Number(label=f"{CF_COL} *", info="Weight percent of total binder")
1090
+ f1_dim = gr.Dropdown(DIM_CHOICES, value=CANON_NA, label="Filler 1 Dimensionality *")
1091
 
1092
  with gr.Accordion("Secondary filler (optional)", open=False, elem_classes=["card"]):
1093
  f2_type = gr.Textbox(label="Filler 2 Type", placeholder="Optional")
1094
  f2_diam = gr.Number(label="Filler 2 Diameter (µm)")
1095
  f2_len = gr.Number(label="Filler 2 Length (mm)")
1096
+ f2_dim = gr.Dropdown(DIM_CHOICES, value=CANON_NA, label="Filler 2 Dimensionality")
1097
 
1098
  with gr.Accordion("Mix design & specimen", open=False, elem_classes=["card"]):
1099
+ spec_vol = gr.Number(label="Specimen Volume (mm3) *")
1100
+ probe_cnt = gr.Number(label="Probe Count *")
1101
+ probe_mat = gr.Textbox(label="Probe Material *", placeholder="e.g., Copper, Silver paste")
1102
+ wb = gr.Number(label="W/B *")
1103
+ sb = gr.Number(label="S/B *")
1104
+ gauge_len = gr.Number(label="Gauge Length (mm) *")
1105
+ curing = gr.Textbox(label="Curing Condition *", placeholder="e.g., 28d water, 20°C")
1106
+ n_fillers = gr.Number(label="Number of Fillers *")
1107
 
1108
  with gr.Accordion("Processing", open=False, elem_classes=["card"]):
1109
  dry_temp = gr.Number(label="Drying Temperature (°C)")
 
1111
 
1112
  with gr.Accordion("Mechanical & electrical loading", open=False, elem_classes=["card"]):
1113
  load_rate = gr.Number(label="Loading Rate (MPa/s)")
1114
+ E_mod = gr.Number(label="Modulus of Elasticity (GPa) *")
1115
+ current = gr.Dropdown(CURRENT_CHOICES, value=CANON_NA, label="Current Type")
1116
  voltage = gr.Number(label="Applied Voltage (V)")
1117
 
1118
  with gr.Column(scale=5):
1119
  with gr.Group(elem_classes=["card"]):
1120
  out_pred = gr.Number(label="Predicted Stress GF (MPa-1)", value=0.0, precision=6, elem_id="pred-out")
1121
+ gr.Markdown(f"<small>{MODEL_STATUS}</small>")
1122
  with gr.Row():
1123
  btn_pred = gr.Button("Predict", variant="primary")
1124
  btn_clear = gr.Button("Clear")
 
1127
  with gr.Accordion("About this model", open=False, elem_classes=["card"]):
1128
  gr.Markdown(
1129
  "- Pipeline: ColumnTransformer → (RobustScaler + OneHot) → XGBoost\n"
1130
+ "- Target: Stress GF (MPa<sup>-1</sup>) on original scale (model may train on log1p; saved flag used at inference).\n"
1131
  "- Missing values are safely imputed per-feature.\n"
1132
  "- Trained columns:\n"
1133
  f" `{', '.join(MAIN_VARIABLES)}`",
 
1153
 
1154
  # ------------------------- Literature Tab -------------------------
1155
  with gr.Tab("📚 Ask the Literature (Hybrid RAG + MMR)", elem_id="rag-tab"):
1156
+ pdf_count = len(list(LOCAL_PDF_DIR.glob("**/*.pdf")))
1157
  gr.Markdown(
1158
+ f"Using local folder <code>papers/</code> **{pdf_count} PDF(s)** indexed. "
1159
+ "Upload more PDFs and reload the Space to expand coverage. "
1160
+ "Answers cite short document codes such as <code>S71</code>, <code>S92</code>."
1161
  )
1162
  with gr.Row():
1163
  top_k = gr.Slider(5, 12, value=8, step=1, label="Top-K chunks")
 
1169
  w_bm25 = gr.Slider(0.0, 1.0, value=W_BM25_DEFAULT, step=0.05, label="BM25 weight")
1170
  w_emb = gr.Slider(0.0, 1.0, value=(0.0 if not USE_DENSE else 0.40), step=0.05, label="Dense weight (set 0 if disabled)")
1171
 
1172
+ # Hidden states (unchanged)
1173
+ state_use_llm = gr.State(LLM_AVAILABLE)
1174
  state_model_name = gr.State(os.getenv("OPENAI_MODEL", OPENAI_MODEL))
1175
  state_temperature = gr.State(0.2)
1176
+ state_strict = gr.State(False)
1177
 
1178
  gr.ChatInterface(
1179
  fn=rag_chat_fn,
 
1183
  w_tfidf, w_bm25, w_emb
1184
  ],
1185
  title="Literature Q&A",
1186
+ description="Hybrid retrieval with diversity. Answers carry inline short-code citations (e.g., (S92), (S71))."
1187
  )
1188
 
1189
+ # ====== Evaluate (Gold vs Logs) ======
1190
+ with gr.Tab("📏 Evaluate (Gold vs Logs)", elem_id="eval-tab"):
1191
+ gr.Markdown("Upload your **gold.csv** and compute metrics against the app logs.")
1192
+ with gr.Row():
1193
+ gold_file = gr.File(label="gold.csv", file_types=[".csv"], interactive=True)
1194
+ k_slider = gr.Slider(3, 12, value=8, step=1, label="k for Hit/Recall/nDCG", elem_id="k-slider")
1195
+ with gr.Row():
1196
+ btn_eval = gr.Button("Compute Metrics", variant="primary")
1197
+ with gr.Row():
1198
+ out_perq = gr.File(label="Per-question metrics (CSV)", elem_id="perq-file")
1199
+ out_agg = gr.File(label="Aggregate metrics (JSON)", elem_id="agg-file")
1200
+ out_json = gr.JSON(label="Aggregate summary", elem_id="agg-json")
1201
+ out_log = gr.Markdown(label="Run log", elem_id="eval-log")
1202
+
1203
+ def _run_eval_inproc(gold_path: str, k: int = 8):
1204
+ import json as _json
1205
+ out_dir = str(ARTIFACT_DIR)
1206
+ logs = str(LOG_PATH)
1207
+ cmd = [
1208
+ sys.executable, "rag_eval_metrics.py",
1209
+ "--gold_csv", gold_path,
1210
+ "--logs_jsonl", logs,
1211
+ "--k", str(k),
1212
+ "--out_dir", out_dir
1213
+ ]
1214
+ try:
1215
+ p = subprocess.run(cmd, capture_output=True, text=True, check=False)
1216
+ stdout = p.stdout or ""
1217
+ stderr = p.stderr or ""
1218
+ perq = ARTIFACT_DIR / "metrics_per_question.csv"
1219
+ agg = ARTIFACT_DIR / "metrics_aggregate.json"
1220
+ agg_json = {}
1221
+ if agg.exists():
1222
+ agg_json = _json.loads(agg.read_text(encoding="utf-8"))
1223
+ report = "```\n" + (stdout.strip() or "(no stdout)") + ("\n" + stderr.strip() if stderr else "") + "\n```"
1224
+ return (str(perq) if perq.exists() else None,
1225
+ str(agg) if agg.exists() else None,
1226
+ agg_json,
1227
+ report)
1228
+ except Exception as e:
1229
+ return (None, None, {}, f"**Eval error:** {e}")
1230
+
1231
+ def _eval_wrapper(gf, k):
1232
+ from pathlib import Path as _Path
1233
+ if gf is None:
1234
+ default_gold = _Path("gold.csv")
1235
+ if not default_gold.exists():
1236
+ return None, None, {}, "**No gold.csv provided or found in repo root.**"
1237
+ gold_path = str(default_gold)
1238
+ else:
1239
+ gold_path = gf.name
1240
+ return _run_eval_inproc(gold_path, int(k))
1241
+
1242
+ btn_eval.click(_eval_wrapper, inputs=[gold_file, k_slider],
1243
+ outputs=[out_perq, out_agg, out_json, out_log])
1244
+
1245
  # ------------- Launch -------------
1246
  if __name__ == "__main__":
1247
  demo.queue().launch()
1248
+
1249
+ # After launch: export a simple list of PDFs as paper_list.csv
1250
+ import os as _os
1251
+ import pandas as _pd
1252
+ folder = "papers"
1253
+ files = sorted(_os.listdir(folder)) if _os.path.exists(folder) else []
1254
+ _pd.DataFrame({"doc": files}).to_csv("paper_list.csv", index=False)
1255
+ print("✅ Saved paper_list.csv with", len(files), "papers")