Spaces:
Sleeping
Sleeping
Upload 10 files
Browse files- .gitignore +8 -0
- Dockerfile +46 -0
- README.md +33 -11
- backend/main.py +415 -0
- backend/requirements.txt +6 -0
- frontend/index.html +16 -0
- frontend/package.json +18 -0
- frontend/src/App.jsx +399 -0
- frontend/src/main.jsx +9 -0
- frontend/vite.config.js +15 -0
.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:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: docker
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
---
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 | 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 · 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} | {e.year} | {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 |
+
});
|