Azizahalq commited on
Commit
7b22421
·
1 Parent(s): 072dcfc

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +165 -161
app.py CHANGED
@@ -1,149 +1,45 @@
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:
@@ -152,19 +48,19 @@ Return two things:
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:
@@ -179,7 +75,8 @@ 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}
@@ -187,9 +84,17 @@ Context snippets (numbered):
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):
@@ -198,13 +103,99 @@ def format_context(hits: List[Tuple[str, str]]) -> Tuple[str, str]:
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"
@@ -212,41 +203,55 @@ def recommend():
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",
@@ -255,11 +260,10 @@ def recommend():
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)
 
1
  #!/usr/bin/env python3
2
  # -*- coding: utf-8 -*-
3
  """
4
+ MaterialMind – Flask app (form page → results page)
5
+ Cloud LLM providers: OpenAI / Together / Hugging Face Inference
6
+ - Set LLM_PROVIDER, LLM_MODEL, LLM_API_KEY in Space Secrets
7
+ - RAG uses dataset Azizahalq/materialmind-corpus (via ensure_ready in rag_mini.py)
 
8
  """
9
 
10
  import os, re, json, textwrap
11
+ from decimal import Decimal
12
+ from typing import List, Tuple
13
+
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
+ # ---- LLM client imports (lazy created in call_llm_cloud) ----
19
+ # (packages added in requirements.txt)
20
 
21
+ # ---- RAG helpers ----
22
+ try:
23
+ # if you applied the dataset-fetch patch
24
+ from rag_mini import search, ensure_ready, DATA_DIR, DEFAULT_TOPK, DEFAULT_MODEL
25
+ except Exception:
26
+ # fallback if ensure_ready is not present
27
+ from rag_mini import search, ensure_dirs as ensure_ready, DATA_DIR, DEFAULT_TOPK, DEFAULT_MODEL
28
 
29
  app = Flask(__name__)
30
+ app.secret_key = "change-me" # set a strong secret in production
31
  CORS(app)
32
 
 
 
 
33
  LOCK_PATH = (DATA_DIR.parent / ".rag_lock")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
+ # ------------- Cloud LLM switch -------------
36
+ LLM_PROVIDER = (os.getenv("LLM_PROVIDER") or "hf").strip().lower()
37
+ LLM_MODEL = (os.getenv("LLM_MODEL") or
38
+ # safe default for HF Inference; change to your choice
39
+ "HuggingFaceH4/zephyr-7b-beta").strip()
40
+ # For OpenAI/Together use LLM_API_KEY; for HF Inference use HUGGINGFACEHUB_API_TOKEN (or set LLM_API_KEY)
41
+ LLM_API_KEY = os.getenv("LLM_API_KEY")
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  SYSTEM_RULES = """You are MaterialMind, a materials-selection assistant.
44
  Return two things:
45
  1) JSON with a ranked shortlist:
 
48
  {
49
  "name": "string",
50
  "score": 0, // 0..400 (sum of 4 independent 0..100 utilities)
51
+ "score_pct": 0, // 0..100 normalized percentage for display
52
  "reasons": ["..."],
53
  "tradeoffs": ["..."],
54
  "citations": ["[1]", "[2]"]
55
  }
56
  ]
57
  }
58
+ 2) After the JSON, provide 3–6 concise bullets on the trade-offs.
59
  Rules:
60
+ - Use only the provided context; cite with [1], [2] etc. No fabrication.
61
+ - Per-criterion utilities are in [0,1]. Cost utility increases as cost decreases.
62
+ - Weights (performance, stability, cost, availability) are independent 0..100 (not normalized).
63
+ - Prefer pitting/crevice metrics for chlorides; keep units explicit.
64
  """
65
 
66
  ANSWER_TEMPLATE = """User constraints:
 
75
  - performance={w_perf}, stability={w_stab}, cost={w_cost}, availability={w_avail}
76
 
77
  Question:
78
+ For {environment} at {temperature}, shortlist materials that meet UTS ≥ {min_uts} MPa and density ≤ {max_density} g/cm^3.
79
+ Consider budget={budget} and process={process}. Rank by performance, stability, cost, and availability.
80
 
81
  Context snippets (numbered):
82
  {context}
 
84
  Citations:
85
  {citations}
86
 
87
+ Now, first output ONLY the JSON block (no preamble). Then the short narrative.
88
  """
89
 
90
+ # ---------- Utils ----------
91
+ def to_dec(x, default: int) -> Decimal:
92
+ try:
93
+ s = (x or "").strip()
94
+ return Decimal(s if s else str(default))
95
+ except Exception:
96
+ return Decimal(default)
97
+
98
  def format_context(hits: List[Tuple[str, str]]) -> Tuple[str, str]:
99
  blocks, cites = [], []
100
  for i, (text, cite) in enumerate(hits, 1):
 
103
  cites.append(f"[{i}] {cite}")
104
  return "\n".join(blocks), "\n".join(cites)
105
 
106
+ def extract_json_block(text: str):
107
+ # fenced JSON first
108
+ m = re.search(r"```json\s*(\{.*?\})\s*```", text, flags=re.S | re.I)
109
+ s = m.group(1) if m else None
110
+ if not s:
111
+ # fallback: first top-level object
112
+ m2 = re.search(r"(\{(?:[^{}]|(?1))*\})", text, flags=re.S)
113
+ s = m2.group(1) if m2 else None
114
+ if not s:
115
+ return None
116
+ try:
117
+ return json.loads(s)
118
+ except Exception:
119
+ last = s.rfind("}")
120
+ if last != -1:
121
+ try:
122
+ return json.loads(s[:last+1])
123
+ except Exception:
124
+ return None
125
+ return None
126
+
127
+ # ---------- Cloud LLM caller ----------
128
+ def call_llm_cloud(system: str, user: str) -> str:
129
+ provider = LLM_PROVIDER
130
+ model = LLM_MODEL
131
+
132
+ if provider in ("openai", "oai"):
133
+ # pip: openai>=1.40
134
+ from openai import OpenAI
135
+ client = OpenAI(api_key=LLM_API_KEY)
136
+ resp = client.chat.completions.create(
137
+ model=model,
138
+ temperature=0.2,
139
+ max_tokens=1200,
140
+ messages=[
141
+ {"role": "system", "content": system},
142
+ {"role": "user", "content": user},
143
+ ],
144
+ )
145
+ return resp.choices[0].message.content
146
+
147
+ elif provider in ("together", "tg"):
148
+ # pip: together>=1.2.0
149
+ from together import Together
150
+ client = Together(api_key=LLM_API_KEY)
151
+ resp = client.chat.completions.create(
152
+ model=model,
153
+ temperature=0.2,
154
+ max_tokens=1200,
155
+ messages=[
156
+ {"role": "system", "content": system},
157
+ {"role": "user", "content": user},
158
+ ],
159
+ )
160
+ return resp.choices[0].message.content
161
+
162
+ else:
163
+ # Hugging Face Inference API
164
+ # token from LLM_API_KEY or HF env
165
+ from huggingface_hub import InferenceClient
166
+ hf_token = LLM_API_KEY or os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACEHUB_API_TOKEN")
167
+ client = InferenceClient(model=model, token=hf_token)
168
+
169
+ # Prefer chat if available, else plain text-generation
170
+ try:
171
+ out = client.chat_completion(
172
+ messages=[
173
+ {"role": "system", "content": system},
174
+ {"role": "user", "content": user},
175
+ ],
176
+ max_tokens=1200,
177
+ temperature=0.2,
178
+ )
179
+ # InferenceClient returns a dataclass-like obj
180
+ return out.choices[0].message["content"]
181
+ except Exception:
182
+ gen = client.text_generation(
183
+ prompt=f"{system}\n\n{user}\n",
184
+ max_new_tokens=1200,
185
+ temperature=0.2,
186
+ do_sample=True,
187
+ stream=False,
188
+ )
189
+ return gen
190
+
191
  # ---------- Routes ----------
192
  @app.get("/")
193
  def index():
194
+ return render_template("index.html", default_model=LLM_MODEL, default_k=DEFAULT_TOPK)
195
 
196
  @app.post("/recommend")
197
  def recommend():
198
+ # Inputs
199
  environment = request.form.get("environment", "").strip() or "seawater"
200
  temperature = request.form.get("temperature", "").strip() or "20–25 °C"
201
  min_uts = request.form.get("min_uts", "").strip() or "0"
 
203
  budget = request.form.get("budget", "").strip() or "open"
204
  process = request.form.get("process", "").strip() or "any"
205
 
206
+ # Independent priorities (0..100 each) hidden from UI via dropdowns
207
+ w_perf = to_dec(request.form.get("w_perf"), 75)
208
+ w_stab = to_dec(request.form.get("w_stab"), 100)
209
+ w_cost = to_dec(request.form.get("w_cost"), 75)
210
+ w_avail= to_dec(request.form.get("w_avail"), 75)
211
 
212
  try:
213
  k = int(request.form.get("k", DEFAULT_TOPK))
214
  except Exception:
215
  k = DEFAULT_TOPK
216
 
217
+ # Build retrieval query & fetch context
218
+ question = (f"For {environment} at {temperature}, shortlist materials that meet "
219
+ f"UTS ≥ {min_uts} MPa and density ≤ {max_density} g/cm^3. "
220
+ f"Consider budget={budget} and process={process}. "
221
+ f"Rank by performance, stability, cost, and availability.")
 
222
 
223
  hits = search(question, k=k)
224
  if not hits:
225
+ flash("No context found. Please add sources or ensure dataset pull succeeded.", "error")
226
  return redirect(url_for("index"))
227
 
228
  ctx, cites = format_context(hits)
229
+
230
+ # Compose prompt
231
  user_prompt = ANSWER_TEMPLATE.format(
232
+ environment=environment, temperature=temperature,
233
+ min_uts=min_uts, max_density=max_density, budget=budget, process=process,
234
+ w_perf=str(int(w_perf)), w_stab=str(int(w_stab)),
235
+ w_cost=str(int(w_cost)), w_avail=str(int(w_avail)),
236
+ context=ctx, citations=cites
237
  )
238
 
239
+ # Call cloud LLM
240
+ try:
241
+ # Use a short lock to prevent concurrent double calls on Spaces
242
+ try:
243
+ LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
244
+ with FileLock(str(LOCK_PATH), timeout=1):
245
+ raw = call_llm_cloud(SYSTEM_RULES, user_prompt)
246
+ except Exception:
247
+ raw = call_llm_cloud(SYSTEM_RULES, user_prompt)
248
+ except Exception as e:
249
+ flash(f"LLM call failed: {e}", "error")
250
+ raw = ""
251
+ candidates = []
252
+ else:
253
+ parsed = extract_json_block(raw) if raw else None
254
+ candidates = (parsed or {}).get("candidates", []) if parsed else []
255
 
256
  return render_template(
257
  "results.html",
 
260
  environment=environment,
261
  temperature=temperature,
262
  raw_output=raw,
263
+ default_model=LLM_MODEL,
264
  default_k=k,
265
  )
266
 
267
  if __name__ == "__main__":
268
+ ensure_ready()
269
+ app.run(host="0.0.0.0", port=7860, debug=False) # Spaces default port