Abhinav633 commited on
Commit
f77ceac
·
verified ·
1 Parent(s): d02eb8e

Upload 10 files

Browse files
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ node_modules/
2
+ frontend/dist/
3
+ __pycache__/
4
+ *.pyc
5
+ .env
6
+ *.egg-info/
7
+ dist/
8
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-slim AS frontend-builder
2
+ WORKDIR /app/frontend
3
+ COPY frontend/package.json frontend/package.json ./
4
+ RUN npm install
5
+ COPY frontend/ ./
6
+ RUN npm run build
7
+
8
+ FROM python:3.11-slim
9
+ WORKDIR /app
10
+
11
+ # Install Python deps
12
+ COPY backend/requirements.txt .
13
+ RUN pip install --no-cache-dir -r requirements.txt
14
+
15
+ # Copy backend
16
+ COPY backend/ ./backend/
17
+
18
+ # Copy built React frontend into backend static folder
19
+ COPY --from=frontend-builder /app/frontend/dist ./backend/static
20
+
21
+ # Patch main.py to serve React static files
22
+ RUN python -c "
23
+ content = open('backend/main.py').read()
24
+ patch = '''
25
+ from fastapi.staticfiles import StaticFiles
26
+ from fastapi.responses import FileResponse
27
+ import os
28
+
29
+ static_dir = os.path.join(os.path.dirname(__file__), 'static')
30
+ if os.path.exists(static_dir):
31
+ app.mount('/assets', StaticFiles(directory=os.path.join(static_dir,'assets')), name='assets')
32
+
33
+ @app.get('/{full_path:path}')
34
+ async def serve_spa(full_path: str):
35
+ index = os.path.join(static_dir, 'index.html')
36
+ return FileResponse(index)
37
+ '''
38
+ # Insert after CORS middleware setup
39
+ insert_after = 'allow_headers=[\"*\"],\n)'
40
+ content = content.replace(insert_after, insert_after + '\n' + patch)
41
+ open('backend/main.py','w').write(content)
42
+ "
43
+
44
+ EXPOSE 7860
45
+
46
+ CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,11 +1,33 @@
1
- ---
2
- title: Protocol Detector
3
- emoji: 📈
4
- colorFrom: red
5
- colorTo: pink
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Protocol Contradiction Detector
3
+ emoji: 🔬
4
+ colorFrom: purple
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # Protocol Contradiction Detector
12
+
13
+ Detect methodological contradictions across experimental research papers using GenAI.
14
+
15
+ ## How to use
16
+
17
+ 1. Upload 2 or more research paper PDFs
18
+ 2. Click **Detect Contradictions**
19
+ 3. Review the ranked contradictions, side-by-side table, and optimal protocol recommendation
20
+ 4. Download the full HTML report
21
+
22
+ ## Setup
23
+
24
+ Add your Groq API key as a **Space Secret**:
25
+ - Go to your Space → Settings → Repository secrets
26
+ - Add a secret named `GROQ_API_KEY` with your key from [console.groq.com](https://console.groq.com)
27
+
28
+ ## Stack
29
+
30
+ - **Frontend**: React + Vite
31
+ - **Backend**: FastAPI + Python
32
+ - **AI**: Groq API (LLaMA 3.3 70B)
33
+ - **PDF parsing**: pdfplumber
backend/main.py ADDED
@@ -0,0 +1,415 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import json
4
+ import tempfile
5
+ from pathlib import Path
6
+ from typing import List
7
+
8
+ import pdfplumber
9
+ from groq import Groq
10
+ from fastapi import FastAPI, File, UploadFile, HTTPException
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from fastapi.responses import JSONResponse, HTMLResponse
13
+ from pydantic import BaseModel
14
+
15
+ app = FastAPI(title="Protocol Contradiction Detector")
16
+
17
+ app.add_middleware(
18
+ CORSMiddleware,
19
+ allow_origins=["*"],
20
+ allow_methods=["*"],
21
+ allow_headers=["*"],
22
+ )
23
+
24
+ # ── Config ────────────────────────────────────────────────────────────────────
25
+ GROQ_MODEL = "llama-3.3-70b-versatile"
26
+ GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
27
+
28
+ # ── Prompts ───────────────────────────────────────────────────────────────────
29
+ EXTRACTION_SYSTEM = """Extract protocol parameters from this research paper. Return ONLY valid JSON:
30
+ {
31
+ "title": "paper title",
32
+ "authors": "first author et al.",
33
+ "year": "year",
34
+ "protocol_type": "e.g. Western blot / ELISA / PCR",
35
+ "parameters": {
36
+ "reagents": [{"name": "...", "concentration": "...", "vendor": "..."}],
37
+ "cell_lines": [{"name": "...", "culture_conditions": "...", "serum": "..."}],
38
+ "temperatures": [{"step": "...", "value": "...", "unit": "C"}],
39
+ "timings": [{"step": "...", "duration": "..."}],
40
+ "equipment": [{"name": "...", "settings": "..."}],
41
+ "buffers": [{"name": "...", "composition": "...", "pH": "..."}],
42
+ "antibodies": [{"target": "...", "dilution": "...", "vendor": "..."}],
43
+ "other": [{"parameter": "...", "value": "..."}]
44
+ }
45
+ }"""
46
+
47
+ COMPARISON_SYSTEM = """You are an expert in bioengineering reproducibility and experimental methodology.
48
+ Compare protocols from multiple research papers and identify ALL methodological contradictions.
49
+ Return ONLY valid JSON (no markdown fences, no extra text) using this schema:
50
+ {
51
+ "protocol_type": "detected protocol type",
52
+ "contradictions": [
53
+ {
54
+ "parameter": "parameter name",
55
+ "category": "reagents|cell_lines|temperatures|timings|equipment|buffers|antibodies|other",
56
+ "severity": "high|medium|low",
57
+ "values": {"paper_0": "value from paper 1", "paper_1": "value from paper 2"},
58
+ "explanation": "why this is a contradiction and its likely impact on reproducibility"
59
+ }
60
+ ],
61
+ "ranked_issues": [
62
+ {"rank": 1, "parameter": "...", "severity": "high|medium|low", "brief": "one-sentence impact"}
63
+ ],
64
+ "optimal_protocol": {
65
+ "rationale": "overall recommendation rationale",
66
+ "parameters": [
67
+ {"label": "parameter name", "value": "recommended value", "reason": "why this is optimal"}
68
+ ]
69
+ },
70
+ "summary": {"total_contradictions": 0, "high": 0, "medium": 0, "low": 0}
71
+ }"""
72
+
73
+ # ── Core helpers ──────────────────────────────────────────────────────────────
74
+
75
+ def extract_text_from_pdf(pdf_path: str) -> str:
76
+ text = ""
77
+ with pdfplumber.open(pdf_path) as pdf:
78
+ for page in pdf.pages:
79
+ page_text = page.extract_text()
80
+ if page_text:
81
+ text += page_text + "\n"
82
+
83
+ if not text.strip() or len(text.strip()) < 500:
84
+ raise ValueError(
85
+ "Could not extract text — this may be a scanned/image-based PDF. "
86
+ "Please use a text-based PDF."
87
+ )
88
+
89
+ # Try to isolate Methods section
90
+ methods_match = re.search(
91
+ r'(?:^\s*(?:\d+\.?\d*\.?\s+)?'
92
+ r'(?:materials?\s+and\s+methods?|experimental\s+procedures?'
93
+ r'|methods?\s+and\s+materials?|methods?)\s*$)'
94
+ r'(.*?)'
95
+ r'(?=^\s*(?:\d+\.?\d*\.?\s+)?'
96
+ r'(?:results?|discussion|conclusion|references|acknowledgements?))',
97
+ text, re.IGNORECASE | re.DOTALL | re.MULTILINE
98
+ )
99
+
100
+ if methods_match and len(methods_match.group(1).strip()) > 200:
101
+ text = methods_match.group(1)
102
+ else:
103
+ chars = len(text)
104
+ text = text[chars // 10: chars // 10 + 15000]
105
+
106
+ words = text.split()
107
+ if len(words) > 2500:
108
+ text = " ".join(words[:2500])
109
+
110
+ return text
111
+
112
+
113
+ def parse_json_response(raw: str) -> dict:
114
+ cleaned = re.sub(r"```(?:json)?", "", raw).strip()
115
+ cleaned = re.sub(r"```", "", cleaned).strip()
116
+ brace_start = cleaned.find("{")
117
+ if brace_start > 0:
118
+ cleaned = cleaned[brace_start:]
119
+
120
+ try:
121
+ return json.loads(cleaned)
122
+ except json.JSONDecodeError:
123
+ pass
124
+
125
+ try:
126
+ open_braces = cleaned.count("{") - cleaned.count("}")
127
+ open_brackets = cleaned.count("[") - cleaned.count("]")
128
+ repaired = cleaned.rstrip(",\n ")
129
+ repaired += "]" * max(open_brackets, 0)
130
+ repaired += "}" * max(open_braces, 0)
131
+ return json.loads(repaired)
132
+ except json.JSONDecodeError:
133
+ pass
134
+
135
+ match = re.search(r"\{.*\}", cleaned, re.DOTALL)
136
+ if match:
137
+ try:
138
+ return json.loads(match.group())
139
+ except json.JSONDecodeError:
140
+ pass
141
+
142
+ raise ValueError("No valid JSON found in response.\n" + raw[:500])
143
+
144
+
145
+ def call_groq_with_backoff(client: Groq, messages: list) -> str:
146
+ word_limits = [2500, 2000, 1500, 1000]
147
+ attempt = 0
148
+ while attempt < len(word_limits):
149
+ try:
150
+ response = client.chat.completions.create(
151
+ model=GROQ_MODEL,
152
+ messages=messages,
153
+ temperature=0.1,
154
+ max_tokens=4000,
155
+ )
156
+ return response.choices[0].message.content
157
+ except Exception as e:
158
+ err = str(e)
159
+ if "413" in err or "too large" in err.lower() or "rate_limit_exceeded" in err.lower():
160
+ attempt += 1
161
+ if attempt >= len(word_limits):
162
+ raise ValueError("Request too large even after maximum reductions.")
163
+ new_limit = word_limits[attempt]
164
+ for msg in messages:
165
+ if msg["role"] == "user":
166
+ words = msg["content"].split()
167
+ msg["content"] = " ".join(words[:new_limit])
168
+ else:
169
+ raise
170
+
171
+
172
+ def extract_protocol(client: Groq, pdf_path: str, filename: str) -> dict:
173
+ paper_text = extract_text_from_pdf(pdf_path)
174
+ messages = [
175
+ {"role": "system", "content": EXTRACTION_SYSTEM},
176
+ {"role": "user", "content":
177
+ "Extract the complete experimental protocol from this research paper. "
178
+ "Be thorough - capture all concentrations, timings, temperatures, cell lines, "
179
+ "reagents, antibodies, and equipment settings.\n\nPAPER TEXT:\n" + paper_text
180
+ }
181
+ ]
182
+ raw = call_groq_with_backoff(client, messages)
183
+ result = parse_json_response(raw)
184
+ result["_filename"] = filename
185
+ return result
186
+
187
+
188
+ def compare_protocols(client: Groq, extracted: list, paper_names: list) -> dict:
189
+ sections = []
190
+ for i, (e, name) in enumerate(zip(extracted, paper_names)):
191
+ sections.append(
192
+ f"=== Paper {i+1}: {e.get('title', name)} ===\n"
193
+ f"{json.dumps(e.get('parameters', {}), indent=2)}"
194
+ )
195
+ prompt = (
196
+ f"Compare these {len(extracted)} research paper protocols "
197
+ f"and identify ALL methodological contradictions:\n\n"
198
+ + "\n\n".join(sections)
199
+ )
200
+ messages = [
201
+ {"role": "system", "content": COMPARISON_SYSTEM},
202
+ {"role": "user", "content": prompt},
203
+ ]
204
+ raw = call_groq_with_backoff(client, messages)
205
+ return parse_json_response(raw)
206
+
207
+
208
+ def generate_html_report(extracted: list, comparison: dict, paper_names: list) -> str:
209
+ summary = comparison.get("summary", {})
210
+ contras = comparison.get("contradictions", [])
211
+ ranked = comparison.get("ranked_issues", [])
212
+ optimal = comparison.get("optimal_protocol", {})
213
+
214
+ sev_color = {"high": "#ff4d6d", "medium": "#ff9f1c", "low": "#2ec4b6"}
215
+ sev_bg = {"high": "#fff0f3", "medium": "#fff8ee", "low": "#f0fafa"}
216
+
217
+ def badge(sev):
218
+ col = sev_color.get(sev, "#888")
219
+ return (f'<span style="background:{col}20;color:{col};border-radius:4px;'
220
+ f'padding:2px 8px;font-size:11px;font-weight:600;text-transform:uppercase;">{sev}</span>')
221
+
222
+ extraction_rows = ""
223
+ for i, e in enumerate(extracted):
224
+ cats = e.get("parameters", {})
225
+ total = sum(len(v) for v in cats.values() if isinstance(v, list))
226
+ extraction_rows += f"""
227
+ <tr>
228
+ <td><b>Paper {i+1}</b><br><span style="color:#888;font-size:12px;">{e.get('_filename','')}</span></td>
229
+ <td>{e.get('title','—')}</td>
230
+ <td>{e.get('authors','—')}</td>
231
+ <td>{e.get('year','—')}</td>
232
+ <td>{e.get('protocol_type','—')}</td>
233
+ <td style="color:#7b2d8b;font-weight:600;">{total}</td>
234
+ </tr>"""
235
+
236
+ contra_rows = ""
237
+ for c in contras:
238
+ sev = c.get("severity", "low")
239
+ col = sev_color.get(sev, "#888")
240
+ vals = c.get("values", {})
241
+ paper_cols = "".join(
242
+ f'<td style="font-family:monospace;font-size:12px;">{vals.get(f"paper_{i}","-")}</td>'
243
+ for i in range(len(paper_names))
244
+ )
245
+ contra_rows += f"""
246
+ <tr>
247
+ <td><b>{c.get('parameter','')}</b></td>
248
+ <td style="color:#888;font-size:12px;">{c.get('category','')}</td>
249
+ <td>{badge(sev)}</td>
250
+ {paper_cols}
251
+ <td style="font-size:12px;color:#444;line-height:1.5;">{c.get('explanation','')}</td>
252
+ </tr>"""
253
+
254
+ ranked_html = ""
255
+ for item in ranked:
256
+ sev = item.get("severity", "low")
257
+ col = sev_color.get(sev, "#888")
258
+ bg = sev_bg.get(sev, "#fafafa")
259
+ ranked_html += f"""
260
+ <div style="display:flex;gap:14px;background:{bg};border:1px solid {col}30;
261
+ border-radius:8px;padding:14px 16px;margin-bottom:8px;">
262
+ <div style="font-size:24px;font-weight:800;color:{col};min-width:34px;">#{item.get('rank','?')}</div>
263
+ <div>
264
+ <div style="font-size:14px;font-weight:600;">{item.get('parameter','')} {badge(sev)}</div>
265
+ <div style="font-size:12px;color:#555;margin-top:4px;line-height:1.5;">{item.get('brief','')}</div>
266
+ </div>
267
+ </div>"""
268
+
269
+ optimal_html = ""
270
+ for p in optimal.get("parameters", []):
271
+ optimal_html += f"""
272
+ <div style="border-left:3px solid #2ec4b6;padding-left:12px;margin-bottom:14px;">
273
+ <div style="font-size:11px;text-transform:uppercase;color:#aaa;">{p.get('label','')}</div>
274
+ <div style="font-size:14px;font-weight:600;color:#1a1a2e;margin:3px 0;">{p.get('value','')}</div>
275
+ <div style="font-size:12px;color:#666;line-height:1.5;">{p.get('reason','')}</div>
276
+ </div>"""
277
+
278
+ paper_th = "".join(
279
+ f'<th>Paper {i+1}<br><span style="font-weight:400;font-size:11px;">{n}</span></th>'
280
+ for i, n in enumerate(paper_names)
281
+ )
282
+
283
+ return f"""<!DOCTYPE html>
284
+ <html lang="en">
285
+ <head>
286
+ <meta charset="UTF-8">
287
+ <meta name="viewport" content="width=device-width,initial-scale=1.0">
288
+ <title>Protocol Contradiction Report</title>
289
+ <style>
290
+ *{{box-sizing:border-box;margin:0;padding:0}}
291
+ body{{font-family:'Segoe UI',sans-serif;background:#f7f7fa;color:#1a1a2e;padding:40px 24px}}
292
+ .container{{max-width:1100px;margin:0 auto}}
293
+ .header{{background:#1a1a2e;color:white;border-radius:12px;padding:32px 36px;margin-bottom:28px}}
294
+ .header h1{{font-size:28px;font-weight:800;margin-bottom:6px}}
295
+ .header p{{color:#aab;font-size:13px}}
296
+ .section{{background:white;border-radius:12px;padding:24px 28px;margin-bottom:20px;box-shadow:0 1px 4px rgba(0,0,0,.07)}}
297
+ .section-title{{font-size:16px;font-weight:700;border-left:4px solid #7b2d8b;padding-left:12px;margin-bottom:18px}}
298
+ .stats{{display:flex;gap:14px;flex-wrap:wrap}}
299
+ .stat{{border-radius:10px;padding:16px 22px;min-width:120px}}
300
+ table{{width:100%;border-collapse:collapse;font-size:13px}}
301
+ th{{background:#f5f0ff;font-size:11px;text-transform:uppercase;letter-spacing:.06em;padding:10px 14px;text-align:left}}
302
+ td{{padding:10px 14px;border-bottom:1px solid #f0f0f5;vertical-align:top}}
303
+ tr:hover td{{background:#fafafa}}
304
+ .optimal-grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px;margin-top:14px}}
305
+ .footer{{text-align:center;font-size:12px;color:#aaa;margin-top:32px}}
306
+ </style>
307
+ </head>
308
+ <body><div class="container">
309
+ <div class="header">
310
+ <h1>Protocol Contradiction Report</h1>
311
+ <p>{len(paper_names)} papers compared &nbsp;|&nbsp; Protocol: {comparison.get('protocol_type','Unknown')}</p>
312
+ </div>
313
+ <div class="section">
314
+ <div class="section-title">Summary</div>
315
+ <div class="stats">
316
+ <div class="stat" style="background:#f5f0ff;border-left:4px solid #7b2d8b">
317
+ <div style="font-size:36px;font-weight:800;color:#7b2d8b">{summary.get('total_contradictions',0)}</div>
318
+ <div style="font-size:11px;color:#888;text-transform:uppercase">Total</div>
319
+ </div>
320
+ <div class="stat" style="background:#fff0f3;border-left:4px solid #ff4d6d">
321
+ <div style="font-size:36px;font-weight:800;color:#ff4d6d">{summary.get('high',0)}</div>
322
+ <div style="font-size:11px;color:#888;text-transform:uppercase">High</div>
323
+ </div>
324
+ <div class="stat" style="background:#fff8ee;border-left:4px solid #ff9f1c">
325
+ <div style="font-size:36px;font-weight:800;color:#ff9f1c">{summary.get('medium',0)}</div>
326
+ <div style="font-size:11px;color:#888;text-transform:uppercase">Medium</div>
327
+ </div>
328
+ <div class="stat" style="background:#f0fafa;border-left:4px solid #2ec4b6">
329
+ <div style="font-size:36px;font-weight:800;color:#2ec4b6">{summary.get('low',0)}</div>
330
+ <div style="font-size:11px;color:#888;text-transform:uppercase">Low</div>
331
+ </div>
332
+ </div>
333
+ </div>
334
+ <div class="section">
335
+ <div class="section-title">Extracted Parameters</div>
336
+ <table><thead><tr><th>Paper</th><th>Title</th><th>Authors</th><th>Year</th><th>Protocol Type</th><th>Parameters</th></tr></thead>
337
+ <tbody>{extraction_rows}</tbody></table>
338
+ </div>
339
+ <div class="section">
340
+ <div class="section-title">Ranked by Severity</div>
341
+ {ranked_html}
342
+ </div>
343
+ <div class="section">
344
+ <div class="section-title">Side-by-side Comparison</div>
345
+ <div style="overflow-x:auto">
346
+ <table><thead><tr><th>Parameter</th><th>Category</th><th>Severity</th>{paper_th}<th>Impact on Reproducibility</th></tr></thead>
347
+ <tbody>{contra_rows}</tbody></table>
348
+ </div>
349
+ </div>
350
+ <div class="section">
351
+ <div class="section-title">Recommended Optimal Protocol</div>
352
+ <p style="font-size:13px;color:#444;line-height:1.6">{optimal.get('rationale','')}</p>
353
+ <div class="optimal-grid">{optimal_html}</div>
354
+ </div>
355
+ <div class="footer">Protocol Contradiction Detector &nbsp;·&nbsp; Powered by Groq + LLaMA 3.3 70B</div>
356
+ </div></body></html>"""
357
+
358
+
359
+ # ── API Routes ────────────────────────────────────────────────────────────────
360
+
361
+ @app.get("/health")
362
+ def health():
363
+ return {"status": "ok", "model": GROQ_MODEL}
364
+
365
+
366
+ @app.post("/analyze")
367
+ async def analyze(files: List[UploadFile] = File(...)):
368
+ if len(files) < 2:
369
+ raise HTTPException(status_code=400, detail="Please upload at least 2 PDF files.")
370
+
371
+ api_key = GROQ_API_KEY
372
+ if not api_key:
373
+ raise HTTPException(status_code=500, detail="GROQ_API_KEY not set in environment.")
374
+
375
+ client = Groq(api_key=api_key)
376
+
377
+ extracted = []
378
+ paper_names = []
379
+ tmp_paths = []
380
+
381
+ try:
382
+ # Save uploads to temp files
383
+ for f in files:
384
+ if not f.filename.lower().endswith(".pdf"):
385
+ raise HTTPException(status_code=400, detail=f"{f.filename} is not a PDF.")
386
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".pdf")
387
+ tmp.write(await f.read())
388
+ tmp.close()
389
+ tmp_paths.append(tmp.name)
390
+ paper_names.append(f.filename)
391
+
392
+ # Extract each paper
393
+ for path, name in zip(tmp_paths, paper_names):
394
+ result = extract_protocol(client, path, name)
395
+ extracted.append(result)
396
+
397
+ # Compare
398
+ comparison = compare_protocols(client, extracted, paper_names)
399
+
400
+ # Build HTML report
401
+ html_report = generate_html_report(extracted, comparison, paper_names)
402
+
403
+ return JSONResponse({
404
+ "extracted": extracted,
405
+ "comparison": comparison,
406
+ "html_report": html_report,
407
+ "paper_names": paper_names,
408
+ })
409
+
410
+ finally:
411
+ for p in tmp_paths:
412
+ try:
413
+ os.unlink(p)
414
+ except Exception:
415
+ pass
backend/requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi==0.111.0
2
+ uvicorn==0.30.1
3
+ python-multipart==0.0.9
4
+ groq==0.9.0
5
+ pdfplumber==0.11.1
6
+ pydantic==2.7.1
frontend/index.html ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Protocol Contradiction Detector</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body { background: #f7f7fa; }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ <script type="module" src="/src/main.jsx"></script>
15
+ </body>
16
+ </html>
frontend/package.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "protocol-detector-frontend",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "vite build",
8
+ "preview": "vite preview"
9
+ },
10
+ "dependencies": {
11
+ "react": "^18.3.1",
12
+ "react-dom": "^18.3.1"
13
+ },
14
+ "devDependencies": {
15
+ "@vitejs/plugin-react": "^4.3.1",
16
+ "vite": "^5.3.1"
17
+ }
18
+ }
frontend/src/App.jsx ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useCallback } from "react";
2
+
3
+ const API_BASE = "/api";
4
+
5
+ const SEV_COLOR = { high: "#ff4d6d", medium: "#ff9f1c", low: "#2ec4b6" };
6
+ const SEV_BG = { high: "#fff0f3", medium: "#fff8ee", low: "#f0fafa" };
7
+
8
+ function Badge({ sev }) {
9
+ const col = SEV_COLOR[sev] || "#888";
10
+ return (
11
+ <span style={{
12
+ background: `${col}20`, color: col, borderRadius: 4,
13
+ padding: "2px 8px", fontSize: 11, fontWeight: 600,
14
+ textTransform: "uppercase", marginLeft: 6,
15
+ }}>{sev}</span>
16
+ );
17
+ }
18
+
19
+ function StatCard({ value, label, color, bg }) {
20
+ return (
21
+ <div style={{
22
+ background: bg, borderLeft: `4px solid ${color}`,
23
+ borderRadius: 10, padding: "16px 22px", minWidth: 110,
24
+ }}>
25
+ <div style={{ fontSize: 36, fontWeight: 800, color }}>{value}</div>
26
+ <div style={{ fontSize: 11, color: "#888", textTransform: "uppercase", letterSpacing: ".06em" }}>{label}</div>
27
+ </div>
28
+ );
29
+ }
30
+
31
+ export default function App() {
32
+ const [files, setFiles] = useState([]);
33
+ const [dragging, setDragging] = useState(false);
34
+ const [loading, setLoading] = useState(false);
35
+ const [progress, setProgress] = useState(0);
36
+ const [statusMsg, setStatusMsg] = useState("");
37
+ const [results, setResults] = useState(null);
38
+ const [error, setError] = useState(null);
39
+ const [activeTab, setActiveTab] = useState("ranked");
40
+ const fileRef = useRef();
41
+
42
+ const addFiles = useCallback((incoming) => {
43
+ const pdfs = Array.from(incoming).filter(f => f.type === "application/pdf");
44
+ if (pdfs.length < incoming.length) {
45
+ setError("Only PDF files are supported.");
46
+ }
47
+ setFiles(prev => {
48
+ const existing = new Set(prev.map(f => f.name));
49
+ return [...prev, ...pdfs.filter(f => !existing.has(f.name))];
50
+ });
51
+ }, []);
52
+
53
+ const removeFile = (name) => setFiles(f => f.filter(x => x.name !== name));
54
+
55
+ const reset = () => {
56
+ setFiles([]); setResults(null); setError(null);
57
+ setProgress(0); setStatusMsg(""); setActiveTab("ranked");
58
+ };
59
+
60
+ const analyze = async () => {
61
+ if (files.length < 2) { setError("Please upload at least 2 PDF files."); return; }
62
+ setError(null); setLoading(true); setProgress(10); setResults(null);
63
+
64
+ // Fake progress while waiting
65
+ const ticker = setInterval(() => {
66
+ setProgress(p => p < 85 ? p + 3 : p);
67
+ }, 1200);
68
+
69
+ const msgs = [
70
+ "Extracting text from PDFs...",
71
+ "Sending to Groq for protocol extraction...",
72
+ "Comparing protocols across papers...",
73
+ "Generating contradiction report...",
74
+ ];
75
+ let mi = 0;
76
+ setStatusMsg(msgs[mi]);
77
+ const msgTicker = setInterval(() => {
78
+ mi = Math.min(mi + 1, msgs.length - 1);
79
+ setStatusMsg(msgs[mi]);
80
+ }, 6000);
81
+
82
+ try {
83
+ const form = new FormData();
84
+ files.forEach(f => form.append("files", f));
85
+
86
+ const resp = await fetch(`${API_BASE}/analyze`, { method: "POST", body: form });
87
+ if (!resp.ok) {
88
+ const err = await resp.json().catch(() => ({}));
89
+ throw new Error(err.detail || `Server error ${resp.status}`);
90
+ }
91
+ const data = await resp.json();
92
+ setProgress(100);
93
+ setStatusMsg("Analysis complete!");
94
+ setResults(data);
95
+ setActiveTab("ranked");
96
+ } catch (e) {
97
+ setError(e.message);
98
+ } finally {
99
+ clearInterval(ticker);
100
+ clearInterval(msgTicker);
101
+ setLoading(false);
102
+ }
103
+ };
104
+
105
+ const downloadReport = () => {
106
+ if (!results?.html_report) return;
107
+ const blob = new Blob([results.html_report], { type: "text/html" });
108
+ const url = URL.createObjectURL(blob);
109
+ const a = document.createElement("a");
110
+ a.href = url;
111
+ a.download = "protocol_report.html";
112
+ a.click();
113
+ URL.revokeObjectURL(url);
114
+ };
115
+
116
+ const comp = results?.comparison || {};
117
+ const summary = comp.summary || {};
118
+ const contras = comp.contradictions || [];
119
+ const ranked = comp.ranked_issues || [];
120
+ const optimal = comp.optimal_protocol || {};
121
+ const paperNames = results?.paper_names || [];
122
+ const extracted = results?.extracted || [];
123
+
124
+ return (
125
+ <div style={{ fontFamily: "'Segoe UI', sans-serif", background: "#f7f7fa", minHeight: "100vh", padding: "40px 24px" }}>
126
+ <div style={{ maxWidth: 1100, margin: "0 auto" }}>
127
+
128
+ {/* ── Header ── */}
129
+ <div style={{ background: "#1a1a2e", borderRadius: 12, padding: "32px 36px", marginBottom: 28, color: "white" }}>
130
+ <div style={{ fontSize: 11, letterSpacing: ".15em", textTransform: "uppercase", color: "#a78bfa", marginBottom: 8 }}>
131
+ Bioengineering · Reproducibility · GenAI
132
+ </div>
133
+ <h1 style={{ fontSize: 28, fontWeight: 800, margin: "0 0 8px" }}>Protocol Contradiction Detector</h1>
134
+ <p style={{ color: "#aab", fontSize: 13, lineHeight: 1.6, maxWidth: 620 }}>
135
+ Upload 2 or more research papers and detect methodological contradictions across experimental protocols —
136
+ reagents, temperatures, cell lines, antibodies, timing and more.
137
+ Powered by <b style={{ color: "#a78bfa" }}>Groq + LLaMA 3.3 70B</b>.
138
+ </p>
139
+ </div>
140
+
141
+ {/* ── Upload section ── */}
142
+ {!results && (
143
+ <div style={{ background: "white", borderRadius: 12, padding: "24px 28px", marginBottom: 20, boxShadow: "0 1px 4px rgba(0,0,0,.07)" }}>
144
+ <div style={{ fontSize: 16, fontWeight: 700, borderLeft: "4px solid #7b2d8b", paddingLeft: 12, marginBottom: 18 }}>
145
+ Upload Research Papers
146
+ </div>
147
+
148
+ {/* Drop zone */}
149
+ <div
150
+ onClick={() => fileRef.current?.click()}
151
+ onDragOver={e => { e.preventDefault(); setDragging(true); }}
152
+ onDragLeave={() => setDragging(false)}
153
+ onDrop={e => { e.preventDefault(); setDragging(false); addFiles(e.dataTransfer.files); }}
154
+ style={{
155
+ border: `2px dashed ${dragging ? "#7b2d8b" : "#ddd"}`,
156
+ borderRadius: 10, padding: "36px 24px", textAlign: "center",
157
+ cursor: "pointer", background: dragging ? "#f5f0ff" : "#fafafa",
158
+ transition: "all .2s", marginBottom: 16,
159
+ }}
160
+ >
161
+ <input ref={fileRef} type="file" multiple accept=".pdf"
162
+ style={{ display: "none" }} onChange={e => addFiles(e.target.files)} />
163
+ <div style={{ fontSize: 32, marginBottom: 10 }}>📄</div>
164
+ <div style={{ fontWeight: 600, color: "#1a1a2e", marginBottom: 4 }}>
165
+ Drag and drop PDFs here
166
+ </div>
167
+ <div style={{ fontSize: 12, color: "#aaa" }}>or click to browse · multiple files supported</div>
168
+ </div>
169
+
170
+ {/* File list */}
171
+ {files.length > 0 && (
172
+ <div style={{ marginBottom: 20 }}>
173
+ {files.map(f => (
174
+ <div key={f.name} style={{
175
+ display: "flex", alignItems: "center", gap: 12,
176
+ background: "#f5f0ff", borderRadius: 6, padding: "8px 14px", marginBottom: 6,
177
+ }}>
178
+ <span style={{ fontSize: 16 }}>📄</span>
179
+ <span style={{ flex: 1, fontSize: 13, color: "#1a1a2e" }}>{f.name}</span>
180
+ <span style={{ fontSize: 11, color: "#aaa" }}>{(f.size / 1024).toFixed(0)} KB</span>
181
+ <button onClick={() => removeFile(f.name)}
182
+ style={{ background: "none", border: "none", cursor: "pointer", color: "#aaa", fontSize: 18, lineHeight: 1 }}>×</button>
183
+ </div>
184
+ ))}
185
+ <div style={{ fontSize: 12, color: "#aaa", marginTop: 4 }}>{files.length} file(s) ready</div>
186
+ </div>
187
+ )}
188
+
189
+ {/* Error */}
190
+ {error && (
191
+ <div style={{ background: "#fff0f3", border: "1px solid #ff4d6d40", borderRadius: 8, padding: "12px 16px", color: "#ff4d6d", fontSize: 13, marginBottom: 16 }}>
192
+ ⚠ {error}
193
+ </div>
194
+ )}
195
+
196
+ {/* Progress */}
197
+ {loading && (
198
+ <div style={{ marginBottom: 20 }}>
199
+ <div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, color: "#888", marginBottom: 6 }}>
200
+ <span>{statusMsg}</span>
201
+ <span>{progress}%</span>
202
+ </div>
203
+ <div style={{ height: 6, background: "#eee", borderRadius: 3, overflow: "hidden" }}>
204
+ <div style={{
205
+ height: "100%", borderRadius: 3,
206
+ background: "linear-gradient(90deg,#7b2d8b,#2ec4b6)",
207
+ width: `${progress}%`, transition: "width .4s ease",
208
+ }} />
209
+ </div>
210
+ </div>
211
+ )}
212
+
213
+ {/* Analyze button */}
214
+ <button
215
+ onClick={analyze}
216
+ disabled={loading || files.length < 2}
217
+ style={{
218
+ padding: "13px 32px", background: files.length >= 2 && !loading ? "#7b2d8b" : "#ddd",
219
+ color: files.length >= 2 && !loading ? "white" : "#aaa",
220
+ border: "none", borderRadius: 8, fontWeight: 700, fontSize: 14,
221
+ cursor: files.length >= 2 && !loading ? "pointer" : "not-allowed",
222
+ transition: "all .2s",
223
+ }}
224
+ >
225
+ {loading ? "Analyzing..." : `Detect Contradictions →`}
226
+ </button>
227
+ {files.length < 2 && !loading && (
228
+ <span style={{ marginLeft: 14, fontSize: 12, color: "#aaa" }}>
229
+ Add {2 - files.length} more paper{files.length === 1 ? "" : "s"} to start
230
+ </span>
231
+ )}
232
+ </div>
233
+ )}
234
+
235
+ {/* ── Results ── */}
236
+ {results && (
237
+ <>
238
+ {/* Results header */}
239
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20, flexWrap: "wrap", gap: 12 }}>
240
+ <div>
241
+ <div style={{ fontSize: 22, fontWeight: 800, color: "#1a1a2e" }}>Analysis Results</div>
242
+ <div style={{ fontSize: 13, color: "#888", marginTop: 3 }}>
243
+ {paperNames.length} papers · {comp.protocol_type || "Protocol"}
244
+ </div>
245
+ </div>
246
+ <div style={{ display: "flex", gap: 10 }}>
247
+ <button onClick={downloadReport} style={{
248
+ padding: "10px 20px", background: "#2ec4b6", color: "white",
249
+ border: "none", borderRadius: 8, fontWeight: 600, fontSize: 13, cursor: "pointer",
250
+ }}>⬇ Download Report</button>
251
+ <button onClick={reset} style={{
252
+ padding: "10px 20px", background: "white", color: "#1a1a2e",
253
+ border: "1px solid #ddd", borderRadius: 8, fontWeight: 600, fontSize: 13, cursor: "pointer",
254
+ }}>← New Analysis</button>
255
+ </div>
256
+ </div>
257
+
258
+ {/* Summary stats */}
259
+ <div style={{ display: "flex", gap: 14, flexWrap: "wrap", marginBottom: 20 }}>
260
+ <StatCard value={summary.total_contradictions ?? 0} label="Total" color="#7b2d8b" bg="#f5f0ff" />
261
+ <StatCard value={summary.high ?? 0} label="High" color="#ff4d6d" bg="#fff0f3" />
262
+ <StatCard value={summary.medium ?? 0} label="Medium" color="#ff9f1c" bg="#fff8ee" />
263
+ <StatCard value={summary.low ?? 0} label="Low" color="#2ec4b6" bg="#f0fafa" />
264
+ </div>
265
+
266
+ {/* Tab nav */}
267
+ <div style={{ display: "flex", gap: 0, borderBottom: "1px solid #eee", marginBottom: 20 }}>
268
+ {[
269
+ { id: "ranked", label: "Ranked Issues" },
270
+ { id: "table", label: "Comparison Table" },
271
+ { id: "optimal", label: "Optimal Protocol" },
272
+ { id: "extraction", label: "Extraction Summary" },
273
+ ].map(t => (
274
+ <button key={t.id} onClick={() => setActiveTab(t.id)} style={{
275
+ padding: "10px 20px", background: "none", border: "none",
276
+ borderBottom: activeTab === t.id ? "2px solid #7b2d8b" : "2px solid transparent",
277
+ color: activeTab === t.id ? "#7b2d8b" : "#888",
278
+ fontWeight: activeTab === t.id ? 600 : 400,
279
+ fontSize: 13, cursor: "pointer", marginBottom: -1,
280
+ }}>{t.label}</button>
281
+ ))}
282
+ </div>
283
+
284
+ {/* Tab: Ranked */}
285
+ {activeTab === "ranked" && (
286
+ <div style={{ background: "white", borderRadius: 12, padding: "24px 28px", boxShadow: "0 1px 4px rgba(0,0,0,.07)" }}>
287
+ {ranked.length === 0
288
+ ? <p style={{ color: "#aaa" }}>No ranked issues found.</p>
289
+ : ranked.map((item, i) => {
290
+ const sev = item.severity || "low";
291
+ const col = SEV_COLOR[sev] || "#888";
292
+ const bg = SEV_BG[sev] || "#fafafa";
293
+ return (
294
+ <div key={i} style={{
295
+ display: "flex", gap: 14, background: bg,
296
+ border: `1px solid ${col}30`, borderRadius: 8,
297
+ padding: "14px 16px", marginBottom: 10,
298
+ }}>
299
+ <div style={{ fontSize: 24, fontWeight: 800, color: col, minWidth: 34 }}>#{item.rank}</div>
300
+ <div>
301
+ <div style={{ fontSize: 14, fontWeight: 600, color: "#1a1a2e" }}>
302
+ {item.parameter}<Badge sev={sev} />
303
+ </div>
304
+ <div style={{ fontSize: 12, color: "#555", marginTop: 4, lineHeight: 1.5 }}>{item.brief}</div>
305
+ </div>
306
+ </div>
307
+ );
308
+ })
309
+ }
310
+ </div>
311
+ )}
312
+
313
+ {/* Tab: Comparison Table */}
314
+ {activeTab === "table" && (
315
+ <div style={{ background: "white", borderRadius: 12, padding: "24px 28px", boxShadow: "0 1px 4px rgba(0,0,0,.07)", overflowX: "auto" }}>
316
+ {contras.length === 0
317
+ ? <p style={{ color: "#aaa" }}>No contradictions found.</p>
318
+ : (
319
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
320
+ <thead>
321
+ <tr>
322
+ {["Parameter", "Category", "Severity",
323
+ ...paperNames.map((n, i) => `Paper ${i + 1}`),
324
+ "Impact on Reproducibility"
325
+ ].map(h => (
326
+ <th key={h} style={{
327
+ background: "#f5f0ff", fontSize: 11, textTransform: "uppercase",
328
+ letterSpacing: ".06em", padding: "10px 14px", textAlign: "left",
329
+ }}>{h}</th>
330
+ ))}
331
+ </tr>
332
+ </thead>
333
+ <tbody>
334
+ {contras.map((c, i) => {
335
+ const sev = c.severity || "low";
336
+ const col = SEV_COLOR[sev] || "#888";
337
+ return (
338
+ <tr key={i}>
339
+ <td style={{ padding: "10px 14px", borderBottom: "1px solid #f0f0f5", fontWeight: 600 }}>{c.parameter}</td>
340
+ <td style={{ padding: "10px 14px", borderBottom: "1px solid #f0f0f5", color: "#888", fontSize: 12 }}>{c.category}</td>
341
+ <td style={{ padding: "10px 14px", borderBottom: "1px solid #f0f0f5" }}><Badge sev={sev} /></td>
342
+ {paperNames.map((_, j) => (
343
+ <td key={j} style={{ padding: "10px 14px", borderBottom: "1px solid #f0f0f5", fontFamily: "monospace", fontSize: 12 }}>
344
+ {c.values?.[`paper_${j}`] || "—"}
345
+ </td>
346
+ ))}
347
+ <td style={{ padding: "10px 14px", borderBottom: "1px solid #f0f0f5", fontSize: 12, color: "#444", lineHeight: 1.5, maxWidth: 260 }}>{c.explanation}</td>
348
+ </tr>
349
+ );
350
+ })}
351
+ </tbody>
352
+ </table>
353
+ )
354
+ }
355
+ </div>
356
+ )}
357
+
358
+ {/* Tab: Optimal Protocol */}
359
+ {activeTab === "optimal" && (
360
+ <div style={{ background: "white", borderRadius: 12, padding: "24px 28px", boxShadow: "0 1px 4px rgba(0,0,0,.07)" }}>
361
+ <p style={{ fontSize: 13, color: "#444", lineHeight: 1.6, marginBottom: 20 }}>{optimal.rationale}</p>
362
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill,minmax(240px,1fr))", gap: 14 }}>
363
+ {(optimal.parameters || []).map((p, i) => (
364
+ <div key={i} style={{ borderLeft: "3px solid #2ec4b6", paddingLeft: 12 }}>
365
+ <div style={{ fontSize: 11, textTransform: "uppercase", color: "#aaa", letterSpacing: ".08em" }}>{p.label}</div>
366
+ <div style={{ fontSize: 14, fontWeight: 600, color: "#1a1a2e", margin: "3px 0" }}>{p.value}</div>
367
+ <div style={{ fontSize: 12, color: "#666", lineHeight: 1.5 }}>{p.reason}</div>
368
+ </div>
369
+ ))}
370
+ </div>
371
+ </div>
372
+ )}
373
+
374
+ {/* Tab: Extraction Summary */}
375
+ {activeTab === "extraction" && (
376
+ <div style={{ background: "white", borderRadius: 12, padding: "24px 28px", boxShadow: "0 1px 4px rgba(0,0,0,.07)" }}>
377
+ {extracted.map((e, i) => {
378
+ const cats = e.parameters || {};
379
+ const total = Object.values(cats).reduce((s, v) => s + (Array.isArray(v) ? v.length : 0), 0);
380
+ return (
381
+ <div key={i} style={{ background: "#fafafa", border: "1px solid #eee", borderRadius: 8, padding: "14px 16px", marginBottom: 12 }}>
382
+ <div style={{ fontWeight: 600, color: "#1a1a2e", marginBottom: 4 }}>Paper {i + 1}: {e.title || e._filename}</div>
383
+ <div style={{ fontSize: 12, color: "#888", marginBottom: 6 }}>
384
+ {e.authors} &nbsp;|&nbsp; {e.year} &nbsp;|&nbsp; {e.protocol_type}
385
+ </div>
386
+ <div style={{ fontSize: 12, color: "#7b2d8b", fontWeight: 600 }}>
387
+ {total} parameters extracted across {Object.keys(cats).length} categories
388
+ </div>
389
+ </div>
390
+ );
391
+ })}
392
+ </div>
393
+ )}
394
+ </>
395
+ )}
396
+ </div>
397
+ </div>
398
+ );
399
+ }
frontend/src/main.jsx ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import App from "./App.jsx";
4
+
5
+ createRoot(document.getElementById("root")).render(
6
+ <StrictMode>
7
+ <App />
8
+ </StrictMode>
9
+ );
frontend/vite.config.js ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ proxy: {
8
+ "/api": {
9
+ target: "http://localhost:7860",
10
+ changeOrigin: true,
11
+ rewrite: (path) => path.replace(/^\/api/, ""),
12
+ },
13
+ },
14
+ },
15
+ });