Azizahalq commited on
Commit
c06e990
·
1 Parent(s): fbe270f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +226 -188
app.py CHANGED
@@ -1,227 +1,265 @@
1
  #!/usr/bin/env python3
2
  # -*- coding: utf-8 -*-
3
  """
4
- MaterialMind (fixed corpus demo)
5
- - Uses YOUR PDFs from ./sources
6
- - Builds a tiny in-memory RAG index at startup (FastEmbed + cosine)
7
- - Cloud LLM scores candidates 0..400 (four 0..100 subscores)
8
- - Simple Gradio UI (no uploads)
9
  """
10
- import os, re, json, textwrap
11
- from pathlib import Path
12
- from typing import List, Tuple, Dict, Any
13
-
14
- import gradio as gr
15
- import requests
16
 
17
- from rag_utils import (
18
- build_index_from_dir, retrieve, format_context_and_cites
 
 
 
 
 
 
 
 
 
 
 
 
19
  )
20
 
21
- # -------------------- LLM client --------------------
22
- PROVIDER = os.getenv("LLM_PROVIDER", "openai").lower() # "openai" | "together"
23
- API_KEY = os.getenv("LLM_API_KEY", "")
24
- MODEL = os.getenv("LLM_MODEL", "gpt-4o-mini") # e.g. Together: "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo"
25
- TIMEOUT = int(os.getenv("LLM_TIMEOUT", "60"))
26
-
27
- def call_llm(system: str, user: str) -> str:
28
- if not API_KEY:
29
- return "[Error] Missing LLM_API_KEY. Add a secret/env var."
30
- if PROVIDER == "together":
31
- base = "https://api.together.xyz/v1"
32
- headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
33
- else:
34
- base = "https://api.openai.com/v1"
35
- headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
36
-
37
- payload = {
38
- "model": MODEL,
39
- "messages": [{"role":"system","content":system},{"role":"user","content":user}],
40
- "temperature": 0.2,
41
- }
42
- r = requests.post(f"{base}/chat/completions", headers=headers, json=payload, timeout=TIMEOUT)
43
- if r.status_code != 200:
44
- return f"[Error] LLM HTTP {r.status_code}: {r.text[:500]}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  try:
46
- return r.json()["choices"][0]["message"]["content"]
47
  except Exception:
48
- return f"[Error] Unexpected LLM response: {r.text[:500]}"
49
-
50
- # -------------------- Prompting --------------------
51
- SYSTEM_RULES = """You are MaterialMind, a general-purpose materials-selection assistant.
52
- Return TWO things:
 
 
53
 
54
- 1) A JSON block with EXACT schema:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  {
56
  "candidates": [
57
  {
58
  "name": "string",
59
- "score": 0, // integer 0..400 (sum of four 0..100 subscores)
60
- "subscores": { "performance": 0, "stability": 0, "cost": 0, "availability": 0 },
61
- "reasons": ["string", "..."],
62
- "tradeoffs": ["string", "..."],
63
- "citations": ["[1]", "[4]"]
64
  }
65
  ]
66
  }
67
-
68
- SCORING (absolute, not weighted):
69
- - performance (0..100): strength/stiffness/thermal range vs user targets
70
- - stability (0..100): corrosion/oxidation/chem/UV/thermal/creep, environment fit
71
- - cost (0..100): relative cost vs user budget (If budget is "Not important", set cost=100)
72
- - availability(0..100): manufacturability, supply forms/lead time
73
-
74
- Total score = performance + stability + cost + availability (0..400). Be conservative; do not invent data.
75
-
76
- 2) After the JSON, add 3–6 concise bullets explaining trade-offs.
77
-
78
  Rules:
79
- - Use ONLY the provided context; cite like [n].
80
- - If critical info is missing, state what to clarify.
81
- - Keep units correct; state assumptions if needed.
 
82
  """
83
 
84
- ANSWER_TEMPLATE = """User constraints
85
- - Application: {environment}
86
  - Temperature: {temperature}
87
- - Targets: UTS {min_uts} MPa, density ≤ {max_density} g/cm^3
88
- - Budget: {budget} • Process: {process}
89
- - Preferences: performance={pref_perf}, stability={pref_stab}, cost={pref_cost}, availability={pref_avail}
 
 
 
 
90
 
91
- Task
92
- Shortlist suitable materials and score them 0..400 using the four 0..100 subscores (see rules).
93
- Explain trade-offs and include citations.
94
 
95
- Context snippets (numbered)
96
  {context}
97
 
98
- Citations
99
  {citations}
100
 
101
- Now first output ONLY the JSON block. Then the bullet narrative.
102
  """
103
 
104
- def extract_json_block(text: str):
105
- m = re.search(r"```json\s*(\{.*?\})\s*```", text, flags=re.S | re.I)
106
- s = m.group(1) if m else None
107
- if not s:
108
- m2 = re.search(r"(\{(?:[^{}]|(?1))*\})", text, flags=re.S)
109
- s = m2.group(1) if m2 else None
110
- if not s: return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  try:
112
- return json.loads(s)
113
  except Exception:
114
- last = s.rfind("}")
115
- if last != -1:
116
- try: return json.loads(s[:last+1])
117
- except Exception: return None
118
- return None
119
-
120
- # -------------------- Build index once (your PDFs) --------------------
121
- SOURCES_DIR = Path(os.getenv("SOURCES_DIR", "sources")).resolve()
122
- INDEX = build_index_from_dir(SOURCES_DIR) # texts, metas, embs (L2-normalized)
123
 
124
- # -------------------- UI callback --------------------
125
- PREF_CHOICES = ["Very high", "High", "Medium", "Low", "Very low"]
126
- COST_CHOICES = ["Not important", "High", "Medium", "Low", "Very low"]
127
-
128
- def recommend(environment, temperature, min_uts, max_density, budget, process,
129
- pref_perf, pref_stab, pref_cost, pref_avail, topk):
130
-
131
- if INDEX["embs"].shape[0] == 0:
132
- return "No context available. Add PDFs to ./sources and redeploy.", None, None
133
 
134
- # Retrieval
135
- q = (f"For {environment or 'general'} at {temperature or 'room temperature'}, shortlist materials that meet "
136
- f"UTS ≥ {min_uts or '0'} MPa and density ≤ {max_density or '100'} g/cm^3; "
137
- f"consider budget={budget or 'open'}, process={process or 'any'}.")
138
- hits = retrieve(INDEX, q, k=int(topk))
139
  if not hits:
140
- return "No extractable context found (OCR may be needed).", None, None
141
- ctx, cites = format_context_and_cites(hits)
142
-
143
- # LLM
144
- prompt = ANSWER_TEMPLATE.format(
145
- environment=environment or "general",
146
- temperature=temperature or "room temperature",
147
- min_uts=min_uts or "0",
148
- max_density=max_density or "100",
149
- budget=budget or "open",
150
- process=process or "any",
151
- pref_perf=pref_perf, pref_stab=pref_stab, pref_cost=pref_cost, pref_avail=pref_avail,
152
- context=ctx, citations=cites
153
  )
154
- raw = call_llm(SYSTEM_RULES, prompt)
 
155
  parsed = extract_json_block(raw) if raw else None
156
- cands = (parsed or {}).get("candidates", []) if parsed else []
157
-
158
- # Format outputs
159
- if not cands:
160
- return raw, None, cites
161
-
162
- headers = ["Rank","Material","Score","Performance","Stability","Cost","Availability","Top reasons"]
163
- rows = []
164
- for i, c in enumerate(sorted(cands, key=lambda x: x.get("score",0), reverse=True), 1):
165
- ss = c.get("subscores", {})
166
- reasons = " • ".join(c.get("reasons", [])[:3])
167
- rows.append([i, c.get("name","?"), c.get("score",0),
168
- ss.get("performance","—"), ss.get("stability","—"),
169
- ss.get("cost","—"), ss.get("availability","—"), reasons])
170
-
171
- # Markdown table
172
- table_md = "| " + " | ".join(headers) + " |\n|" + " --- |"*len(headers) + "\n"
173
- for r in rows:
174
- table_md += "| " + " | ".join(str(x) for x in r) + " |\n"
175
-
176
- # Cards
177
- cards = []
178
- for i, c in enumerate(sorted(cands, key=lambda x: x.get("score",0), reverse=True), 1):
179
- ss = c.get("subscores", {})
180
- card = f"**{i}. {c.get('name','?')}** \n"
181
- card += f"Score {c.get('score',0)} (perf {ss.get('performance','—')}, stab {ss.get('stability','—')}, cost {ss.get('cost','—')}, avail {ss.get('availability','—')})\n\n"
182
- if c.get("tradeoffs"):
183
- card += "**Trade-offs:**\n- " + "\n- ".join(c["tradeoffs"]) + "\n\n"
184
- if c.get("citations"):
185
- card += "**Citations:** " + ", ".join(c["citations"])
186
- cards.append(card)
187
- cards_md = "\n---\n".join(cards)
188
-
189
- return table_md + "\n\n" + raw, cards_md, cites
190
-
191
- # -------------------- Gradio UI --------------------
192
- with gr.Blocks(title="MaterialMind") as demo:
193
- gr.Markdown("## MaterialMind — ranked materials shortlist with page-level citations")
194
- with gr.Row():
195
- environment = gr.Textbox(label="Application", placeholder="seawater / sour service / high-T oxidation")
196
- temperature = gr.Textbox(label="Temperature", placeholder="e.g., 20–25 °C")
197
- with gr.Row():
198
- min_uts = gr.Textbox(label="Min UTS (MPa)", value="0")
199
- max_density = gr.Textbox(label="Max density (g/cm³)", value="100")
200
- with gr.Row():
201
- budget = gr.Dropdown(["open","low","medium","high","Not important"], value="open", label="Budget")
202
- process = gr.Textbox(label="Process", placeholder="wrought / casting / AM / any", value="any")
203
-
204
- gr.Markdown("**Priorities (qualitative; scoring is absolute 0..100 each, total 0..400)**")
205
- with gr.Row():
206
- pref_perf = gr.Dropdown(["Very high","High","Medium","Low","Very low"], value="High", label="Performance")
207
- pref_stab = gr.Dropdown(["Very high","High","Medium","Low","Very low"], value="High", label="Stability")
208
- pref_cost = gr.Dropdown(["Not important","High","Medium","Low","Very low"], value="Medium", label="Cost")
209
- pref_avail = gr.Dropdown(["Very high","High","Medium","Low","Very low"], value="Medium", label="Availability")
210
-
211
- topk = gr.Slider(3, 10, step=1, value=5, label="Top-k context pages")
212
-
213
- run_btn = gr.Button("Get ranked shortlist", variant="primary")
214
- out_table = gr.Markdown(label="Shortlist & raw model output")
215
- out_cards = gr.Markdown(label="Material cards")
216
- out_cites = gr.Markdown(label="Citations (source mapping)")
217
-
218
- run_btn.click(
219
- recommend,
220
- inputs=[environment, temperature, min_uts, max_density, budget, process,
221
- pref_perf, pref_stab, pref_cost, pref_avail, topk],
222
- outputs=[out_table, out_cards, out_cites],
223
- api_name="recommend"
224
  )
225
 
226
  if __name__ == "__main__":
227
- demo.launch()
 
 
 
1
  #!/usr/bin/env python3
2
  # -*- coding: utf-8 -*-
3
  """
4
+ MaterialMind Hugging Face Spaces app
5
+ - Same UI & templates you already have
6
+ - Reads PDFs from HF dataset Azizahalq/materialmind-corpus
7
+ - Builds/updates a local Chroma index at startup
8
+ - Calls an API LLM (OpenAI or Together) via Space secrets
9
  """
 
 
 
 
 
 
10
 
11
+ import os, re, json, textwrap
12
+ import subprocess, shutil
13
+ from typing import List, Tuple, Any, Dict
14
+ from flask import Flask, request, render_template, redirect, url_for, flash
15
+ from flask_cors import CORS
16
+ from filelock import FileLock
17
+
18
+ # Silence tokenizers warning in HF
19
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
20
+
21
+ # ---- RAG helpers (portable) ----
22
+ from rag_mini import (
23
+ ensure_dirs, bootstrap_corpus_and_index, search,
24
+ DATA_DIR, DEFAULT_TOPK
25
  )
26
 
27
+ app = Flask(__name__)
28
+ app.secret_key = os.getenv("FLASK_SECRET", "change-me")
29
+ CORS(app)
30
+
31
+ # HF runs on port 7860
32
+ PORT = int(os.environ.get("PORT", "7860"))
33
+
34
+ LOCK_PATH = (DATA_DIR.parent / ".rag_lock")
35
+ DEFAULT_MODEL = os.getenv("LLM_MODEL", "gpt-4o-mini")
36
+ LLM_PROVIDER = os.getenv("LLM_PROVIDER", "openai") # "openai" or "together"
37
+ LLM_API_KEY = os.getenv("LLM_API_KEY", "")
38
+
39
+ # ---------- LLM caller (remote) ----------
40
+ def call_llm(model: str, system_prompt: str, user_prompt: str) -> str:
41
+ provider = LLM_PROVIDER.lower().strip()
42
+ if provider == "openai":
43
+ try:
44
+ from openai import OpenAI
45
+ client = OpenAI(api_key=LLM_API_KEY)
46
+ resp = client.chat.completions.create(
47
+ model=model,
48
+ temperature=0.2,
49
+ messages=[
50
+ {"role": "system", "content": system_prompt},
51
+ {"role": "user", "content": user_prompt},
52
+ ],
53
+ )
54
+ return resp.choices[0].message.content or ""
55
+ except Exception as e:
56
+ return f"[Error] OpenAI call failed: {e}"
57
+
58
+ elif provider == "together":
59
+ # Simple Together REST call (instruct/chat style)
60
+ import requests
61
+ url = "https://api.together.xyz/v1/chat/completions"
62
+ headers = {"Authorization": f"Bearer {LLM_API_KEY}", "Content-Type": "application/json"}
63
+ payload = {
64
+ "model": model,
65
+ "temperature": 0.2,
66
+ "messages": [
67
+ {"role": "system", "content": system_prompt},
68
+ {"role": "user", "content": user_prompt},
69
+ ],
70
+ }
71
+ try:
72
+ r = requests.post(url, headers=headers, json=payload, timeout=120)
73
+ r.raise_for_status()
74
+ j = r.json()
75
+ return j["choices"][0]["message"]["content"]
76
+ except Exception as e:
77
+ return f"[Error] Together call failed: {e}"
78
+
79
+ return "[Error] Unknown LLM_PROVIDER. Set LLM_PROVIDER to 'openai' or 'together'."
80
+
81
+ def extract_json_block(text: str):
82
+ m = re.search(r"```json\s*(\{.*?\})\s*```", text, flags=re.S | re.I)
83
+ s = m.group(1) if m else None
84
+ if not s:
85
+ m2 = re.search(r"(\{(?:[^{}]|(?1))*\})", text, flags=re.S)
86
+ s = m2.group(1) if m2 else None
87
+ if not s:
88
+ return None
89
  try:
90
+ return json.loads(s)
91
  except Exception:
92
+ last = s.rfind("}")
93
+ if last != -1:
94
+ try:
95
+ return json.loads(s[:last+1])
96
+ except Exception:
97
+ return None
98
+ return None
99
 
100
+ def normalize_candidates_for_display(cands: List[Dict[str, Any]], max_total: float = 400.0) -> List[Dict[str, Any]]:
101
+ def _to_float(x):
102
+ try: return float(x)
103
+ except: return None
104
+
105
+ for c in cands:
106
+ if "score_pct" in c and c["score_pct"] is not None:
107
+ try:
108
+ p = float(c["score_pct"])
109
+ c["score_pct"] = max(0.0, min(100.0, p))
110
+ c.setdefault("score_raw", c["score_pct"] * 4.0)
111
+ continue
112
+ except: pass
113
+
114
+ raw = None
115
+ v = c.get("score")
116
+ if isinstance(v, (int, float)):
117
+ f = float(v)
118
+ raw = (f * max_total) if f <= 1.5 else f
119
+ elif isinstance(v, str):
120
+ s = v.strip()
121
+ m = re.search(r"^\s*([\d.]+)\s*/\s*([\d.]+)\s*$", s)
122
+ if m:
123
+ num, den = _to_float(m.group(1)), _to_float(m.group(2))
124
+ if num is not None and den and den > 0: raw = max_total * (num/den)
125
+ if raw is None:
126
+ m2 = re.search(r"^\s*([\d.]+)\s*%\s*$", s)
127
+ if m2:
128
+ p = _to_float(m2.group(1))
129
+ if p is not None: raw = max_total * (p/100.0)
130
+ if raw is None:
131
+ f = _to_float(s)
132
+ if f is not None: raw = (f * max_total) if f <= 1.5 else f
133
+
134
+ if raw is None:
135
+ subs = c.get("subscores") or {}
136
+ if isinstance(subs, dict) and subs:
137
+ raw = sum(max(0.0, min(100.0, _to_float(v) or 0.0)) for v in subs.values())
138
+
139
+ raw = 0.0 if raw is None else max(0.0, min(max_total, float(raw)))
140
+ c["score_raw"] = raw
141
+ c["score_pct"] = round((raw / max_total) * 100.0, 1)
142
+
143
+ cands.sort(key=lambda z: z.get("score_raw", 0.0), reverse=True)
144
+ return cands
145
+
146
+ # ---------- Prompt text ----------
147
+ SYSTEM_RULES = """You are MaterialMind, a materials-selection assistant.
148
+ Return two things:
149
+ 1) JSON with a ranked shortlist:
150
  {
151
  "candidates": [
152
  {
153
  "name": "string",
154
+ "score": 0, // 0..400 (sum of 4 independent 0..100 utilities)
155
+ "score_pct": 0, // score/4 -> 0..100 for display
156
+ "reasons": ["..."],
157
+ "tradeoffs": ["..."],
158
+ "citations": ["[1]", "[2]"]
159
  }
160
  ]
161
  }
162
+ 2) After the JSON, 3–6 concise bullets on trade-offs.
 
 
 
 
 
 
 
 
 
 
163
  Rules:
164
+ - Use only provided context; cite with [1], [2], etc. No fabrication.
165
+ - Utilities per criterion are in [0,1]. Lower cost => higher cost-utility.
166
+ - Weights (performance, stability, cost, availability) are independent 0..100 (NOT normalized).
167
+ - Prefer pitting/crevice metrics in chloride questions; keep units explicit.
168
  """
169
 
170
+ ANSWER_TEMPLATE = """User constraints:
171
+ - Environment: {environment}
172
  - Temperature: {temperature}
173
+ - Min UTS (MPa): {min_uts}
174
+ - Max density (g/cm^3): {max_density}
175
+ - Budget: {budget}
176
+ - Process: {process}
177
+
178
+ Independent priorities (0..100 each):
179
+ - performance={w_perf}, stability={w_stab}, cost={w_cost}, availability={w_avail}
180
 
181
+ Question:
182
+ {question}
 
183
 
184
+ Context snippets (numbered):
185
  {context}
186
 
187
+ Citations:
188
  {citations}
189
 
190
+ Now, first output ONLY the JSON block, then the short narrative.
191
  """
192
 
193
+ def format_context(hits: List[Tuple[str, str]]) -> Tuple[str, str]:
194
+ blocks, cites = [], []
195
+ for i, (text, cite) in enumerate(hits, 1):
196
+ snippet = textwrap.shorten(text.replace("\n", " "), width=450, placeholder=" …")
197
+ blocks.append(f"[{i}] {snippet}")
198
+ cites.append(f"[{i}] {cite}")
199
+ return "\n".join(blocks), "\n".join(cites)
200
+
201
+ # ---------- Routes ----------
202
+ @app.get("/")
203
+ def index():
204
+ return render_template("index.html", default_model=DEFAULT_MODEL, default_k=DEFAULT_TOPK)
205
+
206
+ @app.post("/recommend")
207
+ def recommend():
208
+ environment = request.form.get("environment", "").strip() or "seawater"
209
+ temperature = request.form.get("temperature", "").strip() or "20–25 °C"
210
+ min_uts = request.form.get("min_uts", "").strip() or "0"
211
+ max_density = request.form.get("max_density", "").strip() or "100"
212
+ budget = request.form.get("budget", "").strip() or "open"
213
+ process = request.form.get("process", "").strip() or "any"
214
+
215
+ # Hidden numeric weights set by JS (0..100 each; independent)
216
+ w_perf = request.form.get("w_perf", "75")
217
+ w_stab = request.form.get("w_stab", "100")
218
+ w_cost = request.form.get("w_cost", "75")
219
+ w_avail = request.form.get("w_avail", "75")
220
+
221
  try:
222
+ k = int(request.form.get("k", DEFAULT_TOPK))
223
  except Exception:
224
+ k = DEFAULT_TOPK
 
 
 
 
 
 
 
 
225
 
226
+ question = (
227
+ f"For {environment} at {temperature}, shortlist materials that meet "
228
+ f"UTS {min_uts} MPa and density {max_density} g/cm^3. "
229
+ f"Consider budget={budget} and process={process}. "
230
+ f"Rank by performance, stability, cost, and availability."
231
+ )
 
 
 
232
 
233
+ hits = search(question, k=k)
 
 
 
 
234
  if not hits:
235
+ flash("No context found. Make sure your dataset is reachable and indexed.", "error")
236
+ return redirect(url_for("index"))
237
+
238
+ ctx, cites = format_context(hits)
239
+ user_prompt = ANSWER_TEMPLATE.format(
240
+ environment=environment, temperature=temperature, min_uts=min_uts,
241
+ max_density=max_density, budget=budget, process=process,
242
+ w_perf=w_perf, w_stab=w_stab, w_cost=w_cost, w_avail=w_avail,
243
+ question=question, context=ctx, citations=cites
 
 
 
 
244
  )
245
+
246
+ raw = call_llm(DEFAULT_MODEL, SYSTEM_RULES, user_prompt)
247
  parsed = extract_json_block(raw) if raw else None
248
+ candidates = (parsed or {}).get("candidates", []) if parsed else []
249
+ candidates = normalize_candidates_for_display(candidates, max_total=400.0)
250
+
251
+ return render_template(
252
+ "results.html",
253
+ candidates=candidates,
254
+ citations=cites.splitlines(),
255
+ environment=environment,
256
+ temperature=temperature,
257
+ raw_output=raw,
258
+ default_model=DEFAULT_MODEL,
259
+ default_k=k,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  )
261
 
262
  if __name__ == "__main__":
263
+ ensure_dirs()
264
+ bootstrap_corpus_and_index() # <-- download dataset + build/update index
265
+ app.run(host="0.0.0.0", port=PORT, debug=False)