Fix AI report: JSON field mapping, local dev proxy, dotenv loading, fuller exam data sent to LLM
Browse files- .env.example +2 -2
- README.md +56 -3
- backend/app.py +27 -1
- src/pages/ReportPage.tsx +113 -69
- vite.config.ts +14 -0
.env.example
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
# Gemini API Key for LLM functionality
|
| 2 |
# Get your API key from: https://makersuite.google.com/app/apikey
|
| 3 |
-
GEMINI_API_KEY=
|
| 4 |
-
VITE_GEMINI_API_KEY=
|
| 5 |
|
| 6 |
# Optional: Model Configuration
|
| 7 |
# GEMINI_MODEL=gemini-1.5-flash
|
|
|
|
| 1 |
# Gemini API Key for LLM functionality
|
| 2 |
# Get your API key from: https://makersuite.google.com/app/apikey
|
| 3 |
+
GEMINI_API_KEY=AIzaSyD9rMJVxxop-FusPIDfcdhJkIIdxa3X4vc
|
| 4 |
+
VITE_GEMINI_API_KEY=AIzaSyD9rMJVxxop-FusPIDfcdhJkIIdxa3X4vc
|
| 5 |
|
| 6 |
# Optional: Model Configuration
|
| 7 |
# GEMINI_MODEL=gemini-1.5-flash
|
README.md
CHANGED
|
@@ -15,21 +15,74 @@ A comprehensive React-based colposcopy assistant application with image annotati
|
|
| 15 |
|
| 16 |
## Local Development
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
```bash
|
| 20 |
npm install
|
| 21 |
```
|
| 22 |
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
| 24 |
```bash
|
| 25 |
npm run dev
|
| 26 |
```
|
| 27 |
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
| 29 |
```bash
|
| 30 |
npm run build
|
| 31 |
```
|
| 32 |
|
|
|
|
| 33 |
## Deploy to Hugging Face Spaces
|
| 34 |
|
| 35 |
This project is configured for deployment on Hugging Face Spaces using Docker runtime.
|
|
|
|
| 15 |
|
| 16 |
## Local Development
|
| 17 |
|
| 18 |
+
The app has two parts that need to run at the same time:
|
| 19 |
+
- **Backend** β FastAPI (Python) on `http://localhost:8000`
|
| 20 |
+
- **Frontend** β Vite dev server (React) on `http://localhost:5173`
|
| 21 |
+
|
| 22 |
+
Vite is pre-configured to proxy all `/api/*` and `/infer/*` requests to the backend.
|
| 23 |
+
|
| 24 |
+
### Step 1 β Set up your Gemini API key
|
| 25 |
+
|
| 26 |
+
Copy `.env.example` to `.env` and fill in your key:
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
cp .env.example .env # or rename a copy manually on Windows
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
Edit `.env`:
|
| 33 |
+
```
|
| 34 |
+
GEMINI_API_KEY=your_actual_gemini_api_key_here
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
Get a free key at: https://makersuite.google.com/app/apikey
|
| 38 |
+
|
| 39 |
+
### Step 2 β Install Python dependencies (backend)
|
| 40 |
+
|
| 41 |
+
It is recommended to use a virtual environment:
|
| 42 |
+
|
| 43 |
+
```bash
|
| 44 |
+
# Create and activate a virtual environment (Windows)
|
| 45 |
+
python -m venv .venv
|
| 46 |
+
.venv\Scripts\activate
|
| 47 |
+
|
| 48 |
+
# Install backend packages
|
| 49 |
+
pip install -r backend/requirements.txt
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
### Step 3 β Start the FastAPI backend
|
| 53 |
+
|
| 54 |
+
From the **project root folder**, run:
|
| 55 |
+
|
| 56 |
+
```bash
|
| 57 |
+
uvicorn backend.app:app --host 0.0.0.0 --port 8000 --reload
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
The API will be available at `http://localhost:8000`.
|
| 61 |
+
You can test the health endpoint: `http://localhost:8000/health`
|
| 62 |
+
|
| 63 |
+
### Step 4 β Install Node.js dependencies (frontend)
|
| 64 |
+
|
| 65 |
```bash
|
| 66 |
npm install
|
| 67 |
```
|
| 68 |
|
| 69 |
+
### Step 5 β Start the Vite dev server
|
| 70 |
+
|
| 71 |
+
In a **separate terminal**, from the project root:
|
| 72 |
+
|
| 73 |
```bash
|
| 74 |
npm run dev
|
| 75 |
```
|
| 76 |
|
| 77 |
+
The app will be available at `http://localhost:5173`.
|
| 78 |
+
|
| 79 |
+
### Step 6 β Build for production
|
| 80 |
+
|
| 81 |
```bash
|
| 82 |
npm run build
|
| 83 |
```
|
| 84 |
|
| 85 |
+
|
| 86 |
## Deploy to Hugging Face Spaces
|
| 87 |
|
| 88 |
This project is configured for deployment on Hugging Face Spaces using Docker runtime.
|
backend/app.py
CHANGED
|
@@ -13,7 +13,33 @@ import uvicorn
|
|
| 13 |
import traceback
|
| 14 |
import json
|
| 15 |
from typing import List, Dict, Optional
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
# Import Google Gemini (optional - graceful degradation if not installed)
|
| 19 |
try:
|
|
|
|
| 13 |
import traceback
|
| 14 |
import json
|
| 15 |
from typing import List, Dict, Optional
|
| 16 |
+
|
| 17 |
+
# Load .env file for local development.
|
| 18 |
+
# Search from this file's directory upward so it works whether the server
|
| 19 |
+
# is launched from project root (uvicorn backend.app:app) or from
|
| 20 |
+
# inside the backend/ folder (python app.py).
|
| 21 |
+
try:
|
| 22 |
+
from dotenv import load_dotenv
|
| 23 |
+
_here = os.path.dirname(os.path.abspath(__file__))
|
| 24 |
+
# Try backend/.env first, then project root .env
|
| 25 |
+
for _env_path in [
|
| 26 |
+
os.path.join(_here, ".env"),
|
| 27 |
+
os.path.join(_here, "..", ".env"),
|
| 28 |
+
]:
|
| 29 |
+
if os.path.isfile(_env_path):
|
| 30 |
+
load_dotenv(_env_path)
|
| 31 |
+
print(f"β
Loaded .env from: {os.path.abspath(_env_path)}")
|
| 32 |
+
break
|
| 33 |
+
else:
|
| 34 |
+
print("β οΈ No .env file found. Set GEMINI_API_KEY in your environment.")
|
| 35 |
+
except ImportError:
|
| 36 |
+
pass
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
from .inference import infer_aw_contour, analyze_frame, analyze_video_frame, infer_cervix_bbox
|
| 41 |
+
except ImportError:
|
| 42 |
+
from inference import infer_aw_contour, analyze_frame, analyze_video_frame, infer_cervix_bbox
|
| 43 |
|
| 44 |
# Import Google Gemini (optional - graceful degradation if not installed)
|
| 45 |
try:
|
src/pages/ReportPage.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import { useEffect, useRef, useState } from 'react';
|
| 2 |
import { ArrowLeft, Download, FileText, CheckCircle, Camera, Sparkles, Loader2 } from 'lucide-react';
|
| 3 |
import html2pdf from 'html2pdf.js';
|
| 4 |
-
import { SYSTEM_PROMPT
|
| 5 |
import { sessionStore } from '../store/sessionStore';
|
| 6 |
|
| 7 |
|
|
@@ -97,26 +97,7 @@ export function ReportPage({
|
|
| 97 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 98 |
}, [JSON.stringify(formData)]);
|
| 99 |
|
| 100 |
-
|
| 101 |
-
const toImagePart = async (src: string): Promise<{ inlineData: { data: string; mimeType: string } } | null> => {
|
| 102 |
-
try {
|
| 103 |
-
const res = await fetch(src);
|
| 104 |
-
const blob = await res.blob();
|
| 105 |
-
return new Promise(resolve => {
|
| 106 |
-
const reader = new FileReader();
|
| 107 |
-
reader.onloadend = () => {
|
| 108 |
-
const dataUrl = reader.result as string;
|
| 109 |
-
const [header, data] = dataUrl.split(',');
|
| 110 |
-
const mimeType = header.match(/:(.*?);/)?.[1] ?? 'image/png';
|
| 111 |
-
resolve({ inlineData: { data, mimeType } });
|
| 112 |
-
};
|
| 113 |
-
reader.onerror = () => resolve(null);
|
| 114 |
-
reader.readAsDataURL(blob);
|
| 115 |
-
});
|
| 116 |
-
} catch {
|
| 117 |
-
return null;
|
| 118 |
-
}
|
| 119 |
-
};
|
| 120 |
|
| 121 |
// On mount: restore all previously saved session data into the form
|
| 122 |
useEffect(() => {
|
|
@@ -177,11 +158,10 @@ export function ReportPage({
|
|
| 177 |
setAiError(null);
|
| 178 |
|
| 179 |
try {
|
| 180 |
-
// Prepare data for backend API
|
| 181 |
const session = sessionStore.get();
|
| 182 |
const mergedFindings = { ...(session.stepFindings ?? {}), ...(stepFindings as Record<string, any>) };
|
| 183 |
|
| 184 |
-
//
|
| 185 |
const patientDataPayload = {
|
| 186 |
name: formData.name,
|
| 187 |
age: formData.age,
|
|
@@ -194,21 +174,24 @@ export function ReportPage({
|
|
| 194 |
patientHistory: session.patientHistory,
|
| 195 |
};
|
| 196 |
|
| 197 |
-
//
|
| 198 |
const examFindingsPayload = {
|
| 199 |
-
native: mergedFindings.native || {},
|
| 200 |
acetowhite: mergedFindings.acetowhite || {},
|
| 201 |
greenFilter: mergedFindings.greenFilter || {},
|
| 202 |
lugol: mergedFindings.lugol || {},
|
|
|
|
|
|
|
| 203 |
};
|
| 204 |
|
| 205 |
-
// Call backend
|
| 206 |
const response = await fetch('/api/generate-report', {
|
| 207 |
method: 'POST',
|
| 208 |
headers: { 'Content-Type': 'application/json' },
|
| 209 |
body: JSON.stringify({
|
| 210 |
patient_data: patientDataPayload,
|
| 211 |
exam_findings: examFindingsPayload,
|
|
|
|
| 212 |
}),
|
| 213 |
});
|
| 214 |
|
|
@@ -218,21 +201,42 @@ export function ReportPage({
|
|
| 218 |
}
|
| 219 |
|
| 220 |
const data = await response.json();
|
| 221 |
-
|
| 222 |
if (data.status !== 'success' || !data.report) {
|
| 223 |
throw new Error('Invalid response from backend');
|
| 224 |
}
|
| 225 |
|
| 226 |
-
//
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
setFormData(prev => ({
|
| 233 |
...prev,
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
}));
|
|
|
|
| 236 |
} catch (err: any) {
|
| 237 |
console.error('Gemini error:', err);
|
| 238 |
const msg = err?.message || err?.toString() || 'Unknown error';
|
|
@@ -242,6 +246,7 @@ export function ReportPage({
|
|
| 242 |
}
|
| 243 |
};
|
| 244 |
|
|
|
|
| 245 |
const handleExportPDF = () => {
|
| 246 |
if (!reportContentRef.current) return;
|
| 247 |
|
|
@@ -477,7 +482,7 @@ export function ReportPage({
|
|
| 477 |
>
|
| 478 |
<CheckCircle className="w-4 h-6" />
|
| 479 |
Save & Continue
|
| 480 |
-
|
| 481 |
</button>
|
| 482 |
</div>
|
| 483 |
{aiError && (
|
|
@@ -584,21 +589,21 @@ export function ReportPage({
|
|
| 584 |
const session = sessionStore.get();
|
| 585 |
const ph = session.patientHistory;
|
| 586 |
if (!ph) return <p className="text-gray-500 italic text-xs">No patient history recorded</p>;
|
| 587 |
-
|
| 588 |
const symptoms = [
|
| 589 |
ph.postCoitalBleeding && 'Post-coital bleeding',
|
| 590 |
ph.interMenstrualBleeding && 'Inter-menstrual bleeding',
|
| 591 |
ph.persistentDischarge && 'Persistent discharge',
|
| 592 |
].filter(Boolean).join(', ');
|
| 593 |
-
|
| 594 |
const pastProc = ph.pastProcedures
|
| 595 |
? Object.entries(ph.pastProcedures).filter(([, v]) => v).map(([k]) => k.toUpperCase()).join(', ')
|
| 596 |
: '';
|
| 597 |
-
|
| 598 |
const immunoList = ph.immunosuppression
|
| 599 |
? Object.entries(ph.immunosuppression).filter(([, v]) => v).map(([k]) => k.toUpperCase()).join(', ')
|
| 600 |
: '';
|
| 601 |
-
|
| 602 |
return (
|
| 603 |
<div className="space-y-1.5 text-xs">
|
| 604 |
{/* Pregnancy Status */}
|
|
@@ -606,32 +611,32 @@ export function ReportPage({
|
|
| 606 |
{ph.pregnancyStatus === 'Pregnant' && ph.gestationalAgeWeeks && <p><strong>Gestational Age:</strong> {ph.gestationalAgeWeeks} weeks</p>}
|
| 607 |
{ph.pregnancyStatus === 'Postpartum' && ph.monthsSinceLastDelivery && <p><strong>Months Since Last Delivery:</strong> {ph.monthsSinceLastDelivery}</p>}
|
| 608 |
{ph.pregnancyStatus === 'Post-abortion' && ph.monthsSinceAbortion && <p><strong>Months Since Abortion:</strong> {ph.monthsSinceAbortion}</p>}
|
| 609 |
-
|
| 610 |
{/* Demographics & Lifestyle */}
|
| 611 |
{ph.menstrualStatus && <p><strong>Menstrual Status:</strong> {ph.menstrualStatus}</p>}
|
| 612 |
{ph.sexualHistory && <p><strong>Sexual History:</strong> {ph.sexualHistory}</p>}
|
| 613 |
-
|
| 614 |
{/* HPV Information */}
|
| 615 |
{ph.hpvStatus && <p><strong>HPV Status:</strong> {ph.hpvStatus}</p>}
|
| 616 |
{ph.hpvVaccination && <p><strong>HPV Vaccination:</strong> {ph.hpvVaccination}</p>}
|
| 617 |
{ph.hpvDnaTypes && <p><strong>HPV DNA Types:</strong> {ph.hpvDnaTypes}</p>}
|
| 618 |
-
|
| 619 |
{/* Presenting Symptoms */}
|
| 620 |
{symptoms && <p><strong>Presenting Symptoms:</strong> {symptoms}</p>}
|
| 621 |
{ph.symptomsNotes && <p><strong>Additional Symptoms:</strong> {ph.symptomsNotes}</p>}
|
| 622 |
-
|
| 623 |
{/* Screening History */}
|
| 624 |
{ph.papSmearResult && <p><strong>PAP Smear Result:</strong> {ph.papSmearResult}</p>}
|
| 625 |
{ph.screeningNotes && <p><strong>Screening Notes:</strong> {ph.screeningNotes}</p>}
|
| 626 |
-
|
| 627 |
{/* Past Procedures */}
|
| 628 |
{pastProc && <p><strong>Past Cervical Procedures:</strong> {pastProc}</p>}
|
| 629 |
-
|
| 630 |
{/* Risk Factors */}
|
| 631 |
{ph.smoking && <p><strong>Smoking History:</strong> {ph.smoking}</p>}
|
| 632 |
{immunoList && <p><strong>Immunosuppression:</strong> {immunoList}</p>}
|
| 633 |
{ph.riskFactorsNotes && <p><strong>Risk Factors Notes:</strong> {ph.riskFactorsNotes}</p>}
|
| 634 |
-
|
| 635 |
{/* Additional Patient Notes */}
|
| 636 |
{ph.patientProfileNotes && <p><strong>Patient Profile Notes:</strong> {ph.patientProfileNotes}</p>}
|
| 637 |
</div>
|
|
@@ -645,33 +650,72 @@ export function ReportPage({
|
|
| 645 |
<div className="bg-[#05998c]/8 border-l-4 border-[#05998c] px-4 py-2.5 print:py-1.5">
|
| 646 |
<h3 className="text-xs font-bold text-[#05998c] uppercase tracking-wider print:text-[10px]">Examination Findings</h3>
|
| 647 |
</div>
|
| 648 |
-
<div className="p-4 print:p-2
|
| 649 |
-
{/*
|
| 650 |
-
<div className="
|
| 651 |
-
<
|
| 652 |
-
|
| 653 |
-
<
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 657 |
</div>
|
| 658 |
-
|
| 659 |
-
{/* Acetic
|
| 660 |
-
<div className="
|
| 661 |
-
<
|
| 662 |
-
|
| 663 |
-
<
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 667 |
</div>
|
| 668 |
</div>
|
| 669 |
</div>
|
| 670 |
|
|
|
|
| 671 |
{/* ββ EXAMINATION IMAGES ββββββββββββββββββββββββββββ */}
|
| 672 |
-
<div
|
| 673 |
className="border border-gray-200 rounded-xl overflow-hidden print:rounded-none print:border-gray-300"
|
| 674 |
-
style={{
|
| 675 |
>
|
| 676 |
<div className="bg-[#05998c]/8 border-l-4 border-[#05998c] px-4 py-2.5 print:py-1.5">
|
| 677 |
<h3 className="text-xs font-bold text-[#05998c] uppercase tracking-wider print:text-[10px]">Examination Images</h3>
|
|
@@ -769,7 +813,7 @@ export function ReportPage({
|
|
| 769 |
<p><strong>Iodine Staining:</strong> {bm.swedeScores.iodineStaining ?? 0}/2</p>
|
| 770 |
</div>
|
| 771 |
</div>
|
| 772 |
-
|
| 773 |
{/* Total Score & Risk */}
|
| 774 |
<div className="bg-gradient-to-br from-[#05998c] to-[#047569] rounded-lg p-3 print:bg-white print:border print:border-teal-200 text-white print:text-gray-900">
|
| 775 |
<p className="text-[10px] font-bold uppercase tracking-wide mb-2 print:mb-1 opacity-90 print:opacity-100">Total Score</p>
|
|
@@ -799,13 +843,13 @@ export function ReportPage({
|
|
| 799 |
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px] block mb-1">Colposcopic Findings (Including Swede Score)</span>
|
| 800 |
<textarea value={formData.colposcopicFindings} onChange={(e) => handleFormChange('colposcopicFindings', e.target.value)} placeholder="Describe colposcopic findings and include Swede score interpretation..." className="w-full text-gray-900 print:text-xs border border-gray-200 focus:border-[#05998c] outline-none bg-white p-2 print:p-1 resize-none rounded transition-colors" rows={3} />
|
| 801 |
</div>
|
| 802 |
-
|
| 803 |
{/* Treatment Plan */}
|
| 804 |
<div>
|
| 805 |
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px] block mb-1">Treatment Plan</span>
|
| 806 |
<textarea value={formData.treatmentPlan} onChange={(e) => handleFormChange('treatmentPlan', e.target.value)} placeholder="Recommended treatment plan..." className="w-full text-gray-900 print:text-xs border border-gray-200 focus:border-[#05998c] outline-none bg-transparent p-2 print:p-1 resize-none rounded transition-colors" rows={3} />
|
| 807 |
</div>
|
| 808 |
-
|
| 809 |
{/* Follow-up Plan */}
|
| 810 |
<div>
|
| 811 |
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px] block mb-1">Follow-up Plan</span>
|
|
|
|
| 1 |
import { useEffect, useRef, useState } from 'react';
|
| 2 |
import { ArrowLeft, Download, FileText, CheckCircle, Camera, Sparkles, Loader2 } from 'lucide-react';
|
| 3 |
import html2pdf from 'html2pdf.js';
|
| 4 |
+
import { SYSTEM_PROMPT } from '../config/geminiConfig';
|
| 5 |
import { sessionStore } from '../store/sessionStore';
|
| 6 |
|
| 7 |
|
|
|
|
| 97 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 98 |
}, [JSON.stringify(formData)]);
|
| 99 |
|
| 100 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
// On mount: restore all previously saved session data into the form
|
| 103 |
useEffect(() => {
|
|
|
|
| 158 |
setAiError(null);
|
| 159 |
|
| 160 |
try {
|
|
|
|
| 161 |
const session = sessionStore.get();
|
| 162 |
const mergedFindings = { ...(session.stepFindings ?? {}), ...(stepFindings as Record<string, any>) };
|
| 163 |
|
| 164 |
+
// Build patient data payload
|
| 165 |
const patientDataPayload = {
|
| 166 |
name: formData.name,
|
| 167 |
age: formData.age,
|
|
|
|
| 174 |
patientHistory: session.patientHistory,
|
| 175 |
};
|
| 176 |
|
| 177 |
+
// Build exam findings payload β include ALL steps + acetic categories + biopsy/swede
|
| 178 |
const examFindingsPayload = {
|
| 179 |
+
native: mergedFindings.native || session.nativeFindings || {},
|
| 180 |
acetowhite: mergedFindings.acetowhite || {},
|
| 181 |
greenFilter: mergedFindings.greenFilter || {},
|
| 182 |
lugol: mergedFindings.lugol || {},
|
| 183 |
+
aceticFindings: session.aceticFindings || {},
|
| 184 |
+
biopsyMarkings: session.biopsyMarkings || {},
|
| 185 |
};
|
| 186 |
|
| 187 |
+
// Call backend β send SYSTEM_PROMPT so Gemini returns strict JSON
|
| 188 |
const response = await fetch('/api/generate-report', {
|
| 189 |
method: 'POST',
|
| 190 |
headers: { 'Content-Type': 'application/json' },
|
| 191 |
body: JSON.stringify({
|
| 192 |
patient_data: patientDataPayload,
|
| 193 |
exam_findings: examFindingsPayload,
|
| 194 |
+
system_prompt: SYSTEM_PROMPT, // β tells Gemini to return JSON
|
| 195 |
}),
|
| 196 |
});
|
| 197 |
|
|
|
|
| 201 |
}
|
| 202 |
|
| 203 |
const data = await response.json();
|
|
|
|
| 204 |
if (data.status !== 'success' || !data.report) {
|
| 205 |
throw new Error('Invalid response from backend');
|
| 206 |
}
|
| 207 |
|
| 208 |
+
// Strip possible markdown code fences before parsing
|
| 209 |
+
let reportText: string = data.report.trim();
|
| 210 |
+
if (reportText.startsWith('```')) {
|
| 211 |
+
reportText = reportText.replace(/^```[a-z]*\n?/i, '').replace(/```\s*$/, '').trim();
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// Parse structured JSON returned by Gemini
|
| 215 |
+
let parsed: Record<string, string> = {};
|
| 216 |
+
try {
|
| 217 |
+
parsed = JSON.parse(reportText);
|
| 218 |
+
} catch {
|
| 219 |
+
// If Gemini didn't return valid JSON, fall back: put full text into colposcopicFindings
|
| 220 |
+
console.warn('AI response was not valid JSON, using as plain text:', reportText);
|
| 221 |
+
setFormData(prev => ({ ...prev, colposcopicFindings: reportText }));
|
| 222 |
+
return;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// Map every JSON key to the correct form field
|
| 226 |
setFormData(prev => ({
|
| 227 |
...prev,
|
| 228 |
+
examQuality: parsed.examQuality ?? prev.examQuality,
|
| 229 |
+
transformationZone: parsed.transformationZone ?? prev.transformationZone,
|
| 230 |
+
acetowL: parsed.acetowL ?? prev.acetowL,
|
| 231 |
+
nativeFindings: parsed.nativeFindings ?? prev.nativeFindings,
|
| 232 |
+
aceticFindings: parsed.aceticFindings ?? prev.aceticFindings,
|
| 233 |
+
colposcopicFindings: parsed.colposcopicFindings ?? prev.colposcopicFindings,
|
| 234 |
+
treatmentPlan: parsed.treatmentPlan ?? prev.treatmentPlan,
|
| 235 |
+
followUp: parsed.followUp ?? prev.followUp,
|
| 236 |
+
biopsySites: parsed.biopsySites ?? prev.biopsySites,
|
| 237 |
+
biopsyNotes: parsed.biopsyNotes ?? prev.biopsyNotes,
|
| 238 |
}));
|
| 239 |
+
|
| 240 |
} catch (err: any) {
|
| 241 |
console.error('Gemini error:', err);
|
| 242 |
const msg = err?.message || err?.toString() || 'Unknown error';
|
|
|
|
| 246 |
}
|
| 247 |
};
|
| 248 |
|
| 249 |
+
|
| 250 |
const handleExportPDF = () => {
|
| 251 |
if (!reportContentRef.current) return;
|
| 252 |
|
|
|
|
| 482 |
>
|
| 483 |
<CheckCircle className="w-4 h-6" />
|
| 484 |
Save & Continue
|
| 485 |
+
|
| 486 |
</button>
|
| 487 |
</div>
|
| 488 |
{aiError && (
|
|
|
|
| 589 |
const session = sessionStore.get();
|
| 590 |
const ph = session.patientHistory;
|
| 591 |
if (!ph) return <p className="text-gray-500 italic text-xs">No patient history recorded</p>;
|
| 592 |
+
|
| 593 |
const symptoms = [
|
| 594 |
ph.postCoitalBleeding && 'Post-coital bleeding',
|
| 595 |
ph.interMenstrualBleeding && 'Inter-menstrual bleeding',
|
| 596 |
ph.persistentDischarge && 'Persistent discharge',
|
| 597 |
].filter(Boolean).join(', ');
|
| 598 |
+
|
| 599 |
const pastProc = ph.pastProcedures
|
| 600 |
? Object.entries(ph.pastProcedures).filter(([, v]) => v).map(([k]) => k.toUpperCase()).join(', ')
|
| 601 |
: '';
|
| 602 |
+
|
| 603 |
const immunoList = ph.immunosuppression
|
| 604 |
? Object.entries(ph.immunosuppression).filter(([, v]) => v).map(([k]) => k.toUpperCase()).join(', ')
|
| 605 |
: '';
|
| 606 |
+
|
| 607 |
return (
|
| 608 |
<div className="space-y-1.5 text-xs">
|
| 609 |
{/* Pregnancy Status */}
|
|
|
|
| 611 |
{ph.pregnancyStatus === 'Pregnant' && ph.gestationalAgeWeeks && <p><strong>Gestational Age:</strong> {ph.gestationalAgeWeeks} weeks</p>}
|
| 612 |
{ph.pregnancyStatus === 'Postpartum' && ph.monthsSinceLastDelivery && <p><strong>Months Since Last Delivery:</strong> {ph.monthsSinceLastDelivery}</p>}
|
| 613 |
{ph.pregnancyStatus === 'Post-abortion' && ph.monthsSinceAbortion && <p><strong>Months Since Abortion:</strong> {ph.monthsSinceAbortion}</p>}
|
| 614 |
+
|
| 615 |
{/* Demographics & Lifestyle */}
|
| 616 |
{ph.menstrualStatus && <p><strong>Menstrual Status:</strong> {ph.menstrualStatus}</p>}
|
| 617 |
{ph.sexualHistory && <p><strong>Sexual History:</strong> {ph.sexualHistory}</p>}
|
| 618 |
+
|
| 619 |
{/* HPV Information */}
|
| 620 |
{ph.hpvStatus && <p><strong>HPV Status:</strong> {ph.hpvStatus}</p>}
|
| 621 |
{ph.hpvVaccination && <p><strong>HPV Vaccination:</strong> {ph.hpvVaccination}</p>}
|
| 622 |
{ph.hpvDnaTypes && <p><strong>HPV DNA Types:</strong> {ph.hpvDnaTypes}</p>}
|
| 623 |
+
|
| 624 |
{/* Presenting Symptoms */}
|
| 625 |
{symptoms && <p><strong>Presenting Symptoms:</strong> {symptoms}</p>}
|
| 626 |
{ph.symptomsNotes && <p><strong>Additional Symptoms:</strong> {ph.symptomsNotes}</p>}
|
| 627 |
+
|
| 628 |
{/* Screening History */}
|
| 629 |
{ph.papSmearResult && <p><strong>PAP Smear Result:</strong> {ph.papSmearResult}</p>}
|
| 630 |
{ph.screeningNotes && <p><strong>Screening Notes:</strong> {ph.screeningNotes}</p>}
|
| 631 |
+
|
| 632 |
{/* Past Procedures */}
|
| 633 |
{pastProc && <p><strong>Past Cervical Procedures:</strong> {pastProc}</p>}
|
| 634 |
+
|
| 635 |
{/* Risk Factors */}
|
| 636 |
{ph.smoking && <p><strong>Smoking History:</strong> {ph.smoking}</p>}
|
| 637 |
{immunoList && <p><strong>Immunosuppression:</strong> {immunoList}</p>}
|
| 638 |
{ph.riskFactorsNotes && <p><strong>Risk Factors Notes:</strong> {ph.riskFactorsNotes}</p>}
|
| 639 |
+
|
| 640 |
{/* Additional Patient Notes */}
|
| 641 |
{ph.patientProfileNotes && <p><strong>Patient Profile Notes:</strong> {ph.patientProfileNotes}</p>}
|
| 642 |
</div>
|
|
|
|
| 650 |
<div className="bg-[#05998c]/8 border-l-4 border-[#05998c] px-4 py-2.5 print:py-1.5">
|
| 651 |
<h3 className="text-xs font-bold text-[#05998c] uppercase tracking-wider print:text-[10px]">Examination Findings</h3>
|
| 652 |
</div>
|
| 653 |
+
<div className="p-4 print:p-2 space-y-3 print:space-y-2">
|
| 654 |
+
{/* Exam Quality / TZ / Acetowhite row */}
|
| 655 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 print:gap-2">
|
| 656 |
+
<div>
|
| 657 |
+
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px] block mb-1">Exam Quality</span>
|
| 658 |
+
<input
|
| 659 |
+
type="text"
|
| 660 |
+
value={formData.examQuality}
|
| 661 |
+
onChange={(e) => handleFormChange('examQuality', e.target.value)}
|
| 662 |
+
placeholder="Adequate / Inadequate"
|
| 663 |
+
className="block w-full font-medium text-gray-900 py-1 print:py-0.5 print:text-xs border-b-2 border-gray-200 focus:border-[#05998c] outline-none bg-transparent transition-colors print:border-gray-400"
|
| 664 |
+
/>
|
| 665 |
+
</div>
|
| 666 |
+
<div>
|
| 667 |
+
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px] block mb-1">Transformation Zone</span>
|
| 668 |
+
<input
|
| 669 |
+
type="text"
|
| 670 |
+
value={formData.transformationZone}
|
| 671 |
+
onChange={(e) => handleFormChange('transformationZone', e.target.value)}
|
| 672 |
+
placeholder="Type I / II / III"
|
| 673 |
+
className="block w-full font-medium text-gray-900 py-1 print:py-0.5 print:text-xs border-b-2 border-gray-200 focus:border-[#05998c] outline-none bg-transparent transition-colors print:border-gray-400"
|
| 674 |
+
/>
|
| 675 |
+
</div>
|
| 676 |
+
<div>
|
| 677 |
+
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px] block mb-1">Acetowhite Lesion</span>
|
| 678 |
+
<input
|
| 679 |
+
type="text"
|
| 680 |
+
value={formData.acetowL}
|
| 681 |
+
onChange={(e) => handleFormChange('acetowL', e.target.value)}
|
| 682 |
+
placeholder="Present / Absent"
|
| 683 |
+
className="block w-full font-medium text-gray-900 py-1 print:py-0.5 print:text-xs border-b-2 border-gray-200 focus:border-[#05998c] outline-none bg-transparent transition-colors print:border-gray-400"
|
| 684 |
+
/>
|
| 685 |
+
</div>
|
| 686 |
</div>
|
| 687 |
+
|
| 688 |
+
{/* Native & Acetic findings side-by-side */}
|
| 689 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 print:gap-2">
|
| 690 |
+
<div className="border border-gray-200 rounded-lg p-3 print:border-gray-300">
|
| 691 |
+
<h4 className="text-xs font-bold text-[#047569] mb-2 print:mb-1">οΏ½ Native Examination</h4>
|
| 692 |
+
<textarea
|
| 693 |
+
value={formData.nativeFindings}
|
| 694 |
+
onChange={(e) => handleFormChange('nativeFindings', e.target.value)}
|
| 695 |
+
placeholder="Click "Generate with AI" to populate findings"
|
| 696 |
+
className="w-full text-gray-900 text-xs border border-gray-200 focus:border-[#05998c] outline-none bg-transparent p-2 print:p-1 resize-none rounded transition-colors"
|
| 697 |
+
rows={4}
|
| 698 |
+
/>
|
| 699 |
+
</div>
|
| 700 |
+
<div className="border border-gray-200 rounded-lg p-3 print:border-gray-300">
|
| 701 |
+
<h4 className="text-xs font-bold text-[#047569] mb-2 print:mb-1">π¬ Acetic Acid Findings</h4>
|
| 702 |
+
<textarea
|
| 703 |
+
value={formData.aceticFindings}
|
| 704 |
+
onChange={(e) => handleFormChange('aceticFindings', e.target.value)}
|
| 705 |
+
placeholder="Click "Generate with AI" to populate findings"
|
| 706 |
+
className="w-full text-gray-900 text-xs border border-gray-200 focus:border-[#05998c] outline-none bg-transparent p-2 print:p-1 resize-none rounded transition-colors"
|
| 707 |
+
rows={4}
|
| 708 |
+
/>
|
| 709 |
+
</div>
|
| 710 |
</div>
|
| 711 |
</div>
|
| 712 |
</div>
|
| 713 |
|
| 714 |
+
|
| 715 |
{/* ββ EXAMINATION IMAGES ββββββββββββββββββββββββββββ */}
|
| 716 |
+
<div
|
| 717 |
className="border border-gray-200 rounded-xl overflow-hidden print:rounded-none print:border-gray-300"
|
| 718 |
+
style={{ marginTop: '150px' }}
|
| 719 |
>
|
| 720 |
<div className="bg-[#05998c]/8 border-l-4 border-[#05998c] px-4 py-2.5 print:py-1.5">
|
| 721 |
<h3 className="text-xs font-bold text-[#05998c] uppercase tracking-wider print:text-[10px]">Examination Images</h3>
|
|
|
|
| 813 |
<p><strong>Iodine Staining:</strong> {bm.swedeScores.iodineStaining ?? 0}/2</p>
|
| 814 |
</div>
|
| 815 |
</div>
|
| 816 |
+
|
| 817 |
{/* Total Score & Risk */}
|
| 818 |
<div className="bg-gradient-to-br from-[#05998c] to-[#047569] rounded-lg p-3 print:bg-white print:border print:border-teal-200 text-white print:text-gray-900">
|
| 819 |
<p className="text-[10px] font-bold uppercase tracking-wide mb-2 print:mb-1 opacity-90 print:opacity-100">Total Score</p>
|
|
|
|
| 843 |
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px] block mb-1">Colposcopic Findings (Including Swede Score)</span>
|
| 844 |
<textarea value={formData.colposcopicFindings} onChange={(e) => handleFormChange('colposcopicFindings', e.target.value)} placeholder="Describe colposcopic findings and include Swede score interpretation..." className="w-full text-gray-900 print:text-xs border border-gray-200 focus:border-[#05998c] outline-none bg-white p-2 print:p-1 resize-none rounded transition-colors" rows={3} />
|
| 845 |
</div>
|
| 846 |
+
|
| 847 |
{/* Treatment Plan */}
|
| 848 |
<div>
|
| 849 |
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px] block mb-1">Treatment Plan</span>
|
| 850 |
<textarea value={formData.treatmentPlan} onChange={(e) => handleFormChange('treatmentPlan', e.target.value)} placeholder="Recommended treatment plan..." className="w-full text-gray-900 print:text-xs border border-gray-200 focus:border-[#05998c] outline-none bg-transparent p-2 print:p-1 resize-none rounded transition-colors" rows={3} />
|
| 851 |
</div>
|
| 852 |
+
|
| 853 |
{/* Follow-up Plan */}
|
| 854 |
<div>
|
| 855 |
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px] block mb-1">Follow-up Plan</span>
|
vite.config.ts
CHANGED
|
@@ -4,4 +4,18 @@ import react from '@vitejs/plugin-react'
|
|
| 4 |
// https://vitejs.dev/config/
|
| 5 |
export default defineConfig({
|
| 6 |
plugins: [react()],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
})
|
|
|
|
| 4 |
// https://vitejs.dev/config/
|
| 5 |
export default defineConfig({
|
| 6 |
plugins: [react()],
|
| 7 |
+
server: {
|
| 8 |
+
proxy: {
|
| 9 |
+
// Forward all /api/* requests to the FastAPI backend
|
| 10 |
+
'/api': {
|
| 11 |
+
target: 'http://localhost:8000',
|
| 12 |
+
changeOrigin: true,
|
| 13 |
+
},
|
| 14 |
+
// Forward all /infer/* requests to the FastAPI backend
|
| 15 |
+
'/infer': {
|
| 16 |
+
target: 'http://localhost:8000',
|
| 17 |
+
changeOrigin: true,
|
| 18 |
+
},
|
| 19 |
+
},
|
| 20 |
+
},
|
| 21 |
})
|