diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..fc5ad9d1335ee74b98356d8d5c121c2519724e1d --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# ── LLM / AI ────────────────────────────────────────────────────────────── +GROQ_API_KEY=gsk_... +COHERE_API_KEY=... + +# ── Supabase ─────────────────────────────────────────────────────────────── +SUPABASE_URL=https://.supabase.co +SUPABASE_KEY=eyJ... +SUPABASE_DB_URL=postgresql+psycopg://:@:5432/postgres + +# ── Google Cloud ─────────────────────────────────────────────────────────── +GOOGLE_VISION_API_KEY=AIza... +GOOGLE_TTS_KEY=AIza... + +# ── Voice TTS (optional — gTTS used as free fallback) ───────────────────── +ELEVENLABS_API_KEY=... + +# ── App ──────────────────────────────────────────────────────────────────── +ENVIRONMENT=production +FRONTEND_URL=https://your-domain.com +NEXT_PUBLIC_API_URL=https://your-domain.com/api + +# ── Next Auth (required if next-auth is enabled) ────────────────────────── +NEXTAUTH_URL=https://your-domain.com +NEXTAUTH_SECRET=change-me-32-chars-minimum + +# ── Monitoring ───────────────────────────────────────────────────────────── +SENTRY_DSN=https://...@o...ingest.sentry.io/... + +# ── Audit logs ───────────────────────────────────────────────────────────── +AUDIT_LOG_DIR=logs/audit diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000000000000000000000000000000000000..b9ef35f696bc46ea3277660aa87491b218b3fae4 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,27 @@ +# ══════════════════════════════════════════════════════ +# تبيان الطبي — Production Environment Variables +# Copy to .env on your server and fill in the values +# ══════════════════════════════════════════════════════ + +# ─── LLM ──────────────────────────────────────────── +GROQ_API_KEY=gsk_... + +# ─── Cohere (reranking + embeddings fallback) ──────── +COHERE_API_KEY=... + +# ─── Supabase ──────────────────────────────────────── +SUPABASE_URL=https://xxxx.supabase.co +SUPABASE_KEY=eyJ... # anon/public key +SUPABASE_SERVICE_KEY=eyJ... # service_role key (bypasses RLS) +SUPABASE_JWT_SECRET=... # JWT Settings → JWT Secret +SUPABASE_DB_URL=postgresql+psycopg://postgres.xxxx:PASSWORD@aws-0-...pooler.supabase.com:5432/postgres + +# ─── Frontend URL (used by backend CORS) ───────────── +FRONTEND_URL=https://yourdomain.com +NEXT_PUBLIC_BACKEND_URL=/ # Use / so Nginx routes /api → backend + +# ─── App ───────────────────────────────────────────── +ENVIRONMENT=production + +# ─── Optional monitoring ───────────────────────────── +SENTRY_DSN= diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000000000000000000000000000000000000..c46f7d29b1ed55641cd20184efc8b74fc1e96186 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,41 @@ +name: Deploy to Production + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + deploy: + name: Deploy to Oracle Cloud + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_SSH_KEY }} + port: 22 + script: | + set -e + cd /opt/tebyan + + echo "── Pulling latest code ──" + git pull origin main + + echo "── Building and restarting ──" + docker compose -f docker-compose.prod.yml build --pull --no-cache backend frontend + docker compose -f docker-compose.prod.yml up -d + + echo "── Waiting for health checks ──" + sleep 15 + curl -sf http://localhost/health | python3 -m json.tool + + echo "── Deploy complete ──" + docker compose -f docker-compose.prod.yml ps diff --git a/.gitignore b/.gitignore index 2f5f7e34f035e922b450a480a532a841258905c5..16fe162a5bcd9e9cd7edcc44cc9f25c3126e163f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,34 @@ frontend/.vercel/ # OS .DS_Store Thumbs.db + +# Logs +logs/ +*.log +backend/logs/ +backend_log.txt + +# Test / temp files +backend/test_*.png +backend/test_*.jpg +backend/eval_results.json +backend/test_set.json + +# pip install artifacts (=version files created by mistake) +=* +backend/=* + +# Office docs +*.docx +*.doc +~$* + +# tsbuildinfo +frontend/tsconfig.tsbuildinfo + +# Large data dirs not needed in prod +assets/ +books/ +data/ +mine/ +temp/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..44ebb8a504fc16a00488b50724e8ba5a30a0088b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,145 @@ +# تبيان الطبي — Agent System + +## Overview + +The multi-agent pipeline in `backend/services/agents/` processes each medical file through five sequential agents. Each agent reads from and writes to a shared `AgentContext` dataclass, making the full pipeline observable and independently testable. + +--- + +## Base Classes (`agents/base.py`) + +### `AgentContext` + +Shared state object threaded through all agents. Key fields: + +| Field | Type | Set by | +|---|---|---| +| `file_bytes` | `bytes` | Coordinator | +| `file_type` | `"pdf" \| "image"` | Coordinator | +| `raw_text` | `str` | OCRAgent | +| `findings` | `list[dict]` | ExtractionAgent | +| `panel_code` | `str` | ClassificationAgent | +| `rag_context` | `str` | MedicalReasoningAgent | +| `report` | `dict` | MedicalReasoningAgent | +| `logs` | `list[AgentLogEntry]` | All agents | + +### `AgentBase` + +Abstract base with: +- **Retry logic**: up to `max_retries=2` attempts with `0.3 s × 2^attempt` backoff +- **`_on_failure(ctx, exc)`**: each subclass overrides to provide a safe fallback when all retries fail +- **Timing**: each `run()` call records `duration_ms` in `AgentLogEntry` + +--- + +## Agents + +### 1. `OCRAgent` + +**File**: `agents/ocr_agent.py` + +**Input**: `ctx.file_bytes`, `ctx.file_type` +**Output**: `ctx.raw_text` + +**Strategy**: +- PDF: extracts text with PyMuPDF (`fitz`); falls back to EasyOCR page-by-page if text layer is empty +- Image: tries Google Cloud Vision first (higher accuracy for Arabic); falls back to EasyOCR with contrast/sharpness preprocessing + +**Failure mode**: sets `raw_text = ""` — downstream agents handle empty text gracefully. + +--- + +### 2. `ExtractionAgent` + +**File**: `agents/extraction_agent.py` + +**Input**: `ctx.raw_text` +**Output**: `ctx.findings` (list of `{name, value, unit, range, status}`) + +**Strategy**: +1. Regex patterns matching common Arabic/English lab report formats +2. LLM extraction via Groq if regex yields < 2 findings +3. Physiological bounds filter (`_validators.py`) removes impossible values (e.g., hemoglobin = 400) +4. Deduplication by normalized test name + +**Failure mode**: sets `findings = []`. + +--- + +### 3. `ClassificationAgent` + +**File**: `agents/classification_agent.py` + +**Input**: `ctx.findings`, `ctx.raw_text` +**Output**: `ctx.panel_code`, `ctx.panel_confidence` + +**Strategy**: Uses `services/classifier.py` which scores text against panel-specific keyword sets. Falls back to `detect_panel()` heuristic if primary classifier returns low confidence. + +**Panels**: `cbc`, `thyroid`, `liver`, `kidney`, `lipid`, `diabetes`, `urine`, `mixed` + +**Failure mode**: sets `panel_code = "mixed"` (general analysis). + +--- + +### 4. `MedicalReasoningAgent` + +**File**: `agents/reasoning_agent.py` + +**Input**: `ctx.findings`, `ctx.panel_code`, `ctx.analysis_type` +**Output**: `ctx.rag_context`, `ctx.report` + +**Strategy**: +1. Checks `rag_cache` (TTL 5 min) for identical query +2. Retrieves relevant medical knowledge via `Retriever` (BM25 + pgvector + Cohere rerank) +3. Selects panel-specific prompt template from `prompts/` +4. Calls Groq `llama-3.3-70b-versatile` with findings + RAG context +5. Parses JSON response into structured report + +**Failure mode**: generates a fallback report from raw findings without LLM, appends disclaimer. + +--- + +### 5. `SafetyAgent` + +**File**: `agents/safety_agent.py` + +**Input**: `ctx.report` +**Output**: `ctx.report` (filtered in-place) + +**Strategy**: Applies `services/safety.filter_analysis_report()` which: +- Removes diagnostic certainty claims ("you have diabetes") +- Adds standard medical disclaimer +- Detects emergency patterns (very high/low critical values) and prepends urgent notice + +**Failure mode**: appends `DISCLAIMER_AR` manually to ensure minimum safety even if filter itself errors. + +--- + +## `AgentCoordinator` (`agents/coordinator.py`) + +Instantiates all five agents and runs them in sequence. Returns `CoordinatorResult`: + +```python +@dataclass +class CoordinatorResult: + findings: list[dict] + summary: str + report: dict + panel_code: str + logs: list[dict] # exposed in dev mode via _agents field + ok: bool + error: str + total_ms: float +``` + +The coordinator is loaded once via `@lru_cache` and reused across requests. Agent instances are stateless — all state lives in the per-request `AgentContext`. + +--- + +## Adding a New Agent + +1. Create `agents/my_agent.py`, subclass `AgentBase` +2. Implement `_execute(self, ctx: AgentContext) -> AgentContext` +3. Implement `_on_failure(self, ctx, exc)` with a safe fallback +4. Add new fields to `AgentContext` if needed +5. Register in `AgentCoordinator.__init__()` agent list at the correct position diff --git a/API_REFERENCE.md b/API_REFERENCE.md new file mode 100644 index 0000000000000000000000000000000000000000..1fec6be064f9d7ce8ad354c7a1f2bbf3bffbbec2 --- /dev/null +++ b/API_REFERENCE.md @@ -0,0 +1,245 @@ +# تبيان الطبي — API Reference + +Base URL: `http://localhost:8000` (dev) / `https://your-domain.com` (prod) + +All endpoints return JSON unless noted. Arabic text is UTF-8 encoded. + +--- + +## `POST /api/analyze` + +Analyze a medical lab report image or PDF. + +**Request**: `multipart/form-data` + +| Field | Type | Required | Description | +|---|---|---|---| +| `file` | File | Yes | PDF or image (JPEG/PNG/WEBP/TIFF). Max 20 MB. | +| `analysis_type` | string | No | One of: `شامل`, `دم شامل`, `سكر وكوليسترول`, `كلى وكبد`, `هرمونات`, `بول`. Default: `شامل` | + +**Response** `200` +```json +{ + "findings": [ + { "name": "Hemoglobin", "value": "10.2", "unit": "g/dL", "range": "12-16", "status": "low" } + ], + "summary": "تُظهر نتائجك انخفاضاً في الهيموجلوبين...", + "report": { + "general": "الحالة العامة...", + "abnormal_details": [{ "اسم_الفحص": "Hemoglobin", "الشرح": "..." }], + "tips": ["..."], + "tips_categorized": [{ "category": "التغذية", "tips": ["..."] }] + } +} +``` + +**Errors**: `400` bad file, `413` file too large, `415` unsupported type, `422` analysis failed, `429` rate limit (5/min) + +--- + +## `POST /api/chat` + +Stream an Arabic medical assistant response. + +**Request**: `application/json` +```json +{ + "query": "ماذا يعني انخفاض الهيموجلوبين؟", + "history": [{ "role": "user", "content": "..." }, { "role": "assistant", "content": "..." }], + "analysis_context": "{\"findings\": [...], \"summary\": \"...\"}" +} +``` + +**Response**: `text/plain; charset=utf-8` — streaming text chunks (Server-Sent Events compatible) + +**Rate limit**: 30/min per IP + +--- + +## `POST /api/risk` + +Assess disease risk from lab findings. + +**Request**: `application/json` +```json +{ + "findings": [ + { "name": "Glucose", "value": "126", "unit": "mg/dL", "range": "70-100", "status": "high" } + ] +} +``` + +**Response** `200` +```json +{ + "risks": [ + { + "condition": "diabetes", + "score": 72, + "level": "high", + "confidence": 0.85, + "label_ar": "السكري", + "factors": ["ارتفاع سكر الصيام"], + "recommendation": "استشر طبيبك لإجراء اختبار HbA1c", + "source": "rule" + } + ], + "top_risk": { ... }, + "overall_ar": "توجد مؤشرات تستوجب المتابعة الطبية", + "features_used": 12 +} +``` + +**Conditions scored**: `diabetes`, `cardiovascular`, `anemia`, `kidney`, `liver`, `thyroid` + +--- + +## `POST /api/voice/transcribe` + +Transcribe Arabic audio to text using Whisper. + +**Request**: `multipart/form-data` + +| Field | Type | Description | +|---|---|---| +| `audio` | File | Audio file. Formats: WebM, MP4, OGG, WAV, FLAC, M4A. Max 25 MB. | +| `language` | string | Language hint. Default: `ar` | + +**Response** `200` +```json +{ "text": "ماذا يعني ارتفاع الكوليسترول؟", "language": "ar" } +``` + +--- + +## `POST /api/voice/synthesize` + +Convert Arabic text to speech (MP3). + +**Request**: `application/json` +```json +{ "text": "نتائج تحليلك تُظهر..." } +``` + +**Response**: `audio/mpeg` binary (MP3). Max input: 3000 characters. + +--- + +## `POST /api/voice/chat` + +Voice-to-voice chat: transcribe audio → chat → synthesize response. + +**Request**: `multipart/form-data` + +| Field | Type | Description | +|---|---|---| +| `audio` | File | Audio file (same formats as transcribe) | +| `analysis_context` | string | JSON string of last analysis result (optional) | + +**Response**: `audio/mpeg` binary — spoken Arabic assistant response. + +--- + +## `POST /api/search` + +Semantic search across saved analyses. + +**Request**: `application/json` +```json +{ + "query": "ارتفاع الكوليسترول", + "analyses": [ + { "id": "uuid", "summary": "...", "findings_text": "Hemoglobin Glucose ..." } + ] +} +``` + +**Response** `200` +```json +{ "scores": { "uuid-1": 0.82, "uuid-2": 0.31 } } +``` + +**Rate limit**: 60/min per IP + +--- + +## `POST /api/analyses/save` + +Save an analysis result for a session. + +**Request**: `application/json` +```json +{ + "session_id": "local_abc123", + "findings": [...], + "summary": "...", + "report": { ... } +} +``` + +**Response** `200`: `{ "success": true, "data": [...] }` + +--- + +## `GET /api/analyses/list` + +List saved analyses for a session. + +**Query params**: `session_id`, `profile_name` (optional), `limit` (default 20) + +**Response** `200`: `{ "analyses": [...] }` + +--- + +## `GET /health` + +System health check. + +**Response** `200` +```json +{ + "ok": true, + "version": "local", + "environment": "development", + "uptime_s": 3600, + "db": { "ok": true, "chunks": 2834, "source": "pgvector/supabase" }, + "model": { "name": "intfloat/multilingual-e5-large", "loaded": true }, + "services": { "groq": true, "cohere": true, "vision": false } +} +``` + +--- + +## `GET /api/metrics` + +Prometheus-format plaintext metrics. + +**Response** `200` `text/plain; version=0.0.4` +``` +# HELP tebyan_uptime_seconds Seconds since server start +tebyan_uptime_seconds 3600 +# HELP tebyan_requests_total Total HTTP requests per path +tebyan_requests_total{path="/api/analyze"} 42 +tebyan_rag_cache_size 18 +tebyan_rag_cache_hits 156 +``` + +--- + +## Error Format + +All errors use standard HTTP status codes with a JSON body: + +```json +{ "detail": "وصف الخطأ بالعربية أو الإنجليزية" } +``` + +| Code | Meaning | +|---|---| +| 400 | Bad request (empty file, invalid input) | +| 413 | File too large (> 20 MB) | +| 415 | Unsupported media type | +| 422 | Analysis failed (OCR/LLM error) | +| 429 | Rate limit exceeded | +| 500 | Internal server error | +| 503 | External service unavailable | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000000000000000000000000000000000000..5ee219b6411331c923fc0192934d183db2c17d97 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,112 @@ +# تبيان الطبي — Architecture + +## System Overview + +Tebyan Medical is a production-grade Arabic medical report analysis platform. Users upload lab reports (PDF or image), the system extracts findings, generates a clinical interpretation in Arabic, and provides an interactive voice-enabled chat assistant. + +``` +Frontend (Next.js 15) Backend (FastAPI) External Services +───────────────────── ───────────────── ───────────────── +Upload → /api/analyze ────────► AgentCoordinator Groq (LLM/STT) +Chat → /api/chat ────────► RAG + LLM streaming Supabase (pgvector) +Voice → /api/voice ────────► WhisperSTT / TTS Cohere (rerank) +Risk → /api/risk ────────► RiskEngine Google Vision/TTS +``` + +--- + +## Backend Layer + +### Entry Point + +`backend/main.py` — FastAPI application. Registers all routes, mounts middleware, and wires dependency-injected singletons via `@lru_cache`. + +### Multi-Agent Pipeline (`services/agents/`) + +Replaces the flat `services/agent/pipeline.py` (kept for backward compat) with a structured agent graph: + +``` +AgentCoordinator +├── OCRAgent — PDF (fitz + EasyOCR) + Image (Google Vision fallback) +├── ExtractionAgent — regex parse → LLM fallback → physiological bounds filter +├── ClassificationAgent — panel detection (CBC, Thyroid, Liver, Kidney, Lipid, Diabetes) +├── MedicalReasoningAgent — RAG retrieval + panel-specific LLM prompt +└── SafetyAgent — PDPL/NDMO compliance filter + emergency detection +``` + +Each agent extends `AgentBase` which provides retry-with-backoff and structured logging via `AgentContext`. + +### RAG Stack (`services/rag/`, `services/search/`) + +- **Embedding**: `intfloat/multilingual-e5-large` (1024-dim, via HuggingFace) +- **Vector store**: Supabase pgvector (`match_documents` RPC, 2834+ medical chunks) +- **Query expansion**: Groq LLM generates 3 alternate queries; `query_parser.py` adds Arabic synonym expansions +- **Retrieval**: BM25 fallback + Cohere `rerank-v3.5` cross-encoder +- **Cache**: 5-minute TTL in-memory LRU (`services/cache.py`) prevents redundant embedding calls + +### Risk Engine (`services/risk/`) + +Evidence-based clinical threshold scoring for 6 conditions. `FeatureExtractor` normalises findings into a 35-feature vector; each scorer (`_score_diabetes`, `_score_cardiovascular`, etc.) applies WHO/ADA/ACC clinical cutoffs. ML `.pkl` model files in `services/risk/models/` override rule-based scores when present. + +### Voice (`services/voice/`) + +- **STT**: `WhisperSTT` wraps Groq `whisper-large-v3`. Accepts WebM/MP4/OGG/WAV (25 MB max). +- **TTS**: Provider chain — Google Cloud TTS (Wavenet-A) → gTTS (free fallback) → ElevenLabs. + +### LLM Router (`services/llm/router.py`) + +`LLMRouter` wraps a primary provider (`GroqProvider`) and optional fallback (`HuggingFaceProvider`). Model selection: `llama-3.3-70b-versatile` for analysis, `llama-3.1-8b-instant` for chat. + +### Security (`middleware/`) + +- **`AuditMiddleware`**: Writes one JSON record per request to rotating log files (`logs/audit/audit.jsonl`). Marks PDPL-sensitive paths. Skips health/docs endpoints. +- **`validate_upload`**: Magic-byte sniffing (anti-MIME-spoofing), 20 MB size limit, extension blocklist. +- **`sanitize_text`**: Strips HTML tags, null bytes, XSS patterns, and SQL injection signatures from all user text inputs. + +### Rate Limiting (`services/ratelimit.py`) + +In-memory sliding window (no external dependency). Limits: analyze=5/min, chat=30/min, search=60/min. Uses `X-Forwarded-For` for IP detection behind proxies. + +--- + +## Frontend Layer + +**Next.js 15 App Router**, RTL Arabic, Tailwind CSS v4, Framer Motion. + +| Component | Purpose | +|---|---| +| `upload-section.tsx` | File picker + `/api/analyze` call + loading state | +| `analysis-history.tsx` | Saved analyses list + semantic search + health trend chart | +| `health-trend-chart.tsx` | Recharts line chart — tracks lab values over time with alerts | +| `risk-dashboard.tsx` | Calls `/api/risk` + renders 6 radial gauge cards (collapsible) | +| `chat-bot.tsx` | Floating chat panel — streaming SSE + voice input/output | +| `voice-recorder.tsx` | MediaRecorder → `/api/voice/transcribe` + TTS playback | +| `compare-analyses.tsx` | Side-by-side analysis diff | + +--- + +## Data Flow — Analysis Request + +``` +1. User uploads PDF/image +2. validate_upload() — size + MIME + magic bytes +3. AgentCoordinator.run() + a. OCRAgent → raw_text + b. ExtractionAgent → findings[] (with impossible-value filter) + c. ClassificationAgent → panel_code + d. MedicalReasoningAgent → RAG context + LLM report + e. SafetyAgent → filtered report +4. Response: { findings, summary, report } +5. Frontend saves to Supabase via /api/analyses/save +6. RiskDashboard calls /api/risk with findings +``` + +--- + +## Key Design Decisions + +- **No streaming for analysis**: Analysis takes 8–15 s; streaming a partial JSON would be malformed. Response is returned in full after all agents complete. +- **RAG cache before agent**: MedicalReasoningAgent checks `rag_cache` before calling pgvector — avoids redundant 300 ms embedding round-trips on identical queries. +- **Agents over monolith**: Each agent is independently retryable and observable via `AgentContext.logs`. Failures degrade gracefully — OCR failure sets `raw_text=""`, downstream agents handle empty input without crashing. +- **Rule-based risk scoring**: ML `.pkl` models are optional overrides. The platform is useful immediately without training data. +- **In-memory rate limiter**: Avoids Redis dependency for MVP. Replace with Redis-backed limiter for multi-process deployments. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000000000000000000000000000000000000..82749c77688b5b430702a1db5a7d9f26d6b29909 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,230 @@ +# تبيان الطبي — Deployment Guide + +## Prerequisites + +- Docker ≥ 24 + Docker Compose v2 +- A server with ≥ 4 GB RAM, ≥ 20 GB disk +- Domain name with DNS pointed to your server (for HTTPS) + +--- + +## 1. Environment Setup + +Copy and populate the environment file: + +```bash +cp .env.example .env +``` + +Required variables: + +``` +GROQ_API_KEY=gsk_... +SUPABASE_URL=https://.supabase.co +SUPABASE_KEY=eyJ... +SUPABASE_DB_URL=postgresql+psycopg://... +FRONTEND_URL=https://your-domain.com +NEXTAUTH_SECRET=<32+ random chars> +``` + +Optional (features degrade gracefully without them): + +``` +COHERE_API_KEY=... # reranking (falls back to BM25 only) +GOOGLE_VISION_API_KEY=... # Vision OCR (falls back to EasyOCR) +GOOGLE_TTS_KEY=... # Google TTS (falls back to gTTS) +ELEVENLABS_API_KEY=... # ElevenLabs TTS +SENTRY_DSN=... # Error monitoring +``` + +--- + +## 2. Nginx Configuration + +Create `nginx/nginx.conf`: + +```nginx +events { worker_connections 1024; } + +http { + upstream backend { server backend:8000; } + upstream frontend { server frontend:3000; } + + server { + listen 80; + server_name your-domain.com; + return 301 https://$host$request_uri; + } + + server { + listen 443 ssl; + server_name your-domain.com; + + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + + client_max_body_size 25M; + + location /api/ { proxy_pass http://backend; proxy_set_header Host $host; } + location /health { proxy_pass http://backend; } + location / { proxy_pass http://frontend; proxy_set_header Host $host; } + } +} +``` + +Place TLS certificates in `nginx/certs/` (Let's Encrypt recommended). + +--- + +## 3. Frontend Dockerfile + +Create `frontend/Dockerfile`: + +```dockerfile +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +ARG NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +RUN npm run build + +FROM node:20-alpine +WORKDIR /app +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public +ENV PORT=3000 +EXPOSE 3000 +CMD ["node", "server.js"] +``` + +Enable standalone output in `next.config.js`: + +```js +module.exports = { output: "standalone" } +``` + +--- + +## 4. Deploy + +```bash +# Build and start all services +docker compose -f docker-compose.prod.yml up -d --build + +# Check logs +docker compose -f docker-compose.prod.yml logs -f backend + +# Check health +curl https://your-domain.com/health +``` + +--- + +## 5. Supabase Setup + +Run once to set up the pgvector extension and required tables: + +```sql +-- Enable pgvector +create extension if not exists vector; + +-- Documents table for RAG +create table if not exists documents ( + id bigserial primary key, + content text, + embedding vector(1024), + metadata jsonb +); +create index on documents using ivfflat (embedding vector_cosine_ops) with (lists = 100); + +-- match_documents RPC +create or replace function match_documents( + query_embedding vector(1024), + match_count int default 10, + filter jsonb default '{}' +) +returns table(id bigint, content text, metadata jsonb, similarity float) +language plpgsql as $$ +begin + return query + select d.id, d.content, d.metadata, + 1 - (d.embedding <=> query_embedding) as similarity + from documents d + order by d.embedding <=> query_embedding + limit match_count; +end; +$$; + +-- Analyses table +create table if not exists analyses ( + id uuid default gen_random_uuid() primary key, + session_id text not null, + findings jsonb, + summary text, + report jsonb, + created_at timestamptz default now() +); +create index on analyses(session_id, created_at desc); +``` + +--- + +## 6. PEFT/LoRA Fine-Tuning (Optional) + +To improve Arabic medical analysis quality with your own data: + +```bash +# 1. Prepare dataset from Supabase analyses +python training/prepare_dataset.py --source supabase --output_dir data/ + +# 2. Train LoRA adapter (requires GPU with ≥16 GB VRAM) +python training/train_lora.py \ + --model_name core42/jais-13b-chat \ + --data_dir data/ \ + --output_dir checkpoints/tebyan-v1 \ + --num_epochs 3 \ + --load_4bit + +# 3. Evaluate +python training/evaluate_model.py \ + --base_model core42/jais-13b-chat \ + --lora_adapter checkpoints/tebyan-v1/lora_adapter \ + --val_data data/val.jsonl \ + --use_llm_judge \ + --groq_key $GROQ_API_KEY +``` + +Results are written to `eval_results/eval_summary.json`. + +--- + +## 7. Monitoring + +The `/api/metrics` endpoint exposes Prometheus-format metrics. Scrape with: + +```yaml +# prometheus.yml +scrape_configs: + - job_name: tebyan + static_configs: + - targets: ['your-domain.com'] + metrics_path: /api/metrics + scheme: https +``` + +Audit logs are written to the `audit_logs` Docker volume at `logs/audit/audit.jsonl` (rotating, max 50 MB × 10 files). + +--- + +## 8. Updating + +```bash +git pull +docker compose -f docker-compose.prod.yml up -d --build --no-deps backend +docker compose -f docker-compose.prod.yml up -d --build --no-deps frontend +``` + +The `--no-deps` flag updates only the specified service without restarting nginx or volumes. diff --git a/app.py b/app.py deleted file mode 100644 index 22ca0dffa1b41458609356608011fc36ec1dbbee..0000000000000000000000000000000000000000 --- a/app.py +++ /dev/null @@ -1,726 +0,0 @@ -import os -os.environ['TRANSFORMERS_VERBOSITY'] = 'error' -import io -import streamlit as st -import google.generativeai as genai -import numpy as np -from PIL import Image -import fitz -import easyocr -import re -from langchain_huggingface import HuggingFaceEmbeddings -from langchain_community.vectorstores import Chroma - -# --- 1. الإعدادات --- -os.environ['HF_HOME'] = r'D:\Project\model_cache' -API_KEY = "AIzaSyDA4UjdmbAAGGgdrYLYRQtJYHrnawYdka4" -genai.configure(api_key=API_KEY) - -st.set_page_config(page_title="تبيان الطبي", layout="wide", page_icon="🩺") - -# معالجة رسائل الشات في أول الصفحة -_chat_q = st.query_params.get("q", "") -if _chat_q: - if "chat_msgs" not in st.session_state: - st.session_state.chat_msgs = [{"role":"bot","text":"مرحباً! أنا مساعدك الطبي 🩺"}] - st.session_state.chat_msgs.append({"role":"user","text":_chat_q}) - st.query_params.clear() - -# --- 2. CSS --- -# إخفاء مساحة iframe الشات -st.markdown("""""", unsafe_allow_html=True) -st.markdown(""" - - -""", unsafe_allow_html=True) - -# --- 3. الهيدر --- -st.markdown(""" -
- -
-
- تحليل ذكي للتقارير الطبية -
-
-""", unsafe_allow_html=True) - -# --- 4. Hero --- -st.markdown(""" -
-
🤖 مدعوم بالذكاء الاصطناعي
-

افهم نتائج تحاليلك بلغة عربية بسيطة

-

نحوّل المصطلحات الطبية المعقّدة إلى شرح واضح، مع توصيات دقيقة مدعومة بمراجع طبية موثوقة.

-
-""", unsafe_allow_html=True) - - -# --- 5. الدوال --- -@st.cache_resource -def load_tools(): - reader = easyocr.Reader(['en'], gpu=False) - embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2") - try: - available_models = [m.name for m in genai.list_models() if 'generateContent' in m.supported_generation_methods] - if 'models/gemini-1.5-flash' in available_models: - model_name = 'models/gemini-1.5-flash' - elif 'models/gemini-pro' in available_models: - model_name = 'models/gemini-pro' - else: - model_name = available_models[0] - except: - model_name = 'models/gemini-1.5-flash' - print(f"✅ الموديل المختار: {model_name}") - return reader, embeddings, genai.GenerativeModel(model_name) - -reader, embeddings, model = load_tools() - -# رد الشات بعد تحميل الموديل - run_rag -print(f"DEBUG _chat_q = '{_chat_q}'") -if _chat_q and "chat_msgs" in st.session_state: - if st.session_state.chat_msgs and st.session_state.chat_msgs[-1]["role"] == "user": - try: - # فلتر طبي - strong_words = ["ألم","صداع","تعب","دوخة","حمى","سعال","غثيان","قيء","أعراض","ضغط","سكر","قلب","معدة","تنفس","التهاب","دواء","علاج"] - weak_words = ["تحليل","دم","فيتامين","مختبر","نتائج","فحص","تشخيص","كوليسترول","حديد"] - score = sum(0.6 for w in strong_words if w in _chat_q) + sum(0.3 for w in weak_words if w in _chat_q) + 0.5 - - if score < 0.8: - _ans = "هذا النظام مخصص للأسئلة الطبية فقط." - else: - # RAG - context = "" - try: - if os.path.exists(r'D:\Project\chroma_db'): - _db = Chroma(persist_directory=r'D:\Project\chroma_db', embedding_function=embeddings) - results = _db.similarity_search_with_relevance_scores(_chat_q, k=3) - filtered = [doc for doc, s in results if s >= 0.8] - if filtered: - context = "\n\n".join([d.page_content for d in filtered]) - print("✅ تم استخدام RAG") - except Exception as e: - print(f"RAG error: {e}") - - prompt = f""" -أنت مساعد طبي ذكي. - -أجب على السؤال الطبي التالي بشكل واضح ومبسط. - -إذا كانت الحالة مرتبطة بأعراض أو مشاكل صحية، يجب عليك في نهاية الإجابة إضافة قسم بعنوان: - -"التحاليل المقترحة (إن وجدت):" - -وتذكر التحاليل المناسبة التي قد يطلبها الطبيب حسب الحالة فقط (إن لزم الأمر). - -إذا لم تكن هناك حاجة لتحاليل، لا تذكر هذا القسم. - -{("السياق من المراجع:\\n" + context) if context else ""} - -السؤال: -{_chat_q} -""" - _ans = model.generate_content(prompt).text - - st.session_state.chat_msgs.append({"role":"bot","text":_ans}) - except Exception as e: - st.session_state.chat_msgs.append({"role":"bot","text":f"حدث خطأ: {e}"}) - st.rerun() - -def is_valid_test(name): - ignore = ['page','id','patient','date','sex','age','mrn','doctor','physician','result','unit','range','validated','approved','Interpretation','Ref'] - name_l = str(name).lower() - return not any(x in name_l for x in ignore) and len(str(name).strip()) > 1 - -def get_status_color(value, range_str): - try: - nums = re.findall(r"[-+]?\d*\.?\d+", str(range_str)) - val = float(value) - if len(nums) >= 2: - low, high = float(nums[0]), float(nums[1]) - if val < low or val > high: - return "status-high", "badge-high", "خارج المعدل" - return "status-normal", "badge-normal", "ممتاز" - except: - pass - return "status-warning", "badge-warning", "تحليل القيمة" - - -# --- 6. Upload --- -uploaded_file = st.file_uploader("اسحب ملف التحليل هنا", type=['pdf','png','jpg','jpeg']) -st.markdown('

🔒 ملفاتك محمية ولا تُحفظ على خوادمنا

', unsafe_allow_html=True) - -if not uploaded_file: - st.markdown(""" -
-
🔬

استخراج ذكي

يستخرج نتائج التحاليل تلقائياً من PDF أو صورة بدقة عالية.

-
📚

مراجع طبية موثوقة

التقارير مدعومة بمراجع من قاعدة بياناتك الطبية المحلية.

-
🛡️

خصوصية كاملة

بياناتك الطبية لا تُحفظ ولا تُشارك مع أي طرف خارجي.

-
- """, unsafe_allow_html=True) - - -# --- 7. المعالجة --- -if uploaded_file: - with st.spinner("جاري استخراج البيانات الطبية..."): - if uploaded_file.type == "application/pdf": - doc = fitz.open(stream=uploaded_file.read(), filetype="pdf") - all_text = "\n".join([page.get_text() for page in doc]) - else: - img = Image.open(uploaded_file) - all_text = "\n".join(reader.readtext(np.array(img), detail=0)) - - pattern = r"([a-zA-Z][a-zA-Z\s#%]{2,})\s+(\d+\.?\d*)\s+([\d\.]+\s*-\s*[\d\.]+)\s*([a-zA-Z0-9^/]+)?" - findings = [f for f in re.findall(pattern, all_text) if is_valid_test(f[0])] - - if len(findings) < 2: - with st.spinner("تنسيق غير تقليدي.. جاري الاستخراج بالذكاء الاصطناعي..."): - extract_prompt = f"""حلل النص الطبي التالي واستخرج نتائج التحاليل. -النص: {all_text[:4000]} -أجب بتنسيق قائمة بايثون فقط: [('Test Name', 'Value', 'Range', 'Unit')] -لا تكتب أي مقدمات أو شرح، فقط القائمة.""" - try: - ai_response = model.generate_content(extract_prompt) - clean_text = ai_response.text.strip().replace("```python","").replace("```","") - findings = eval(clean_text) - except: - pass - - if findings: - abnormal = [f for f in findings if is_valid_test(f[0]) and get_status_color(f[1], f[2])[0] != "status-normal"] - abnormal_names = "، ".join([f[0] for f in abnormal[:3]]) if abnormal else "لا توجد قيم خارج المعدل" - - st.markdown(f""" -
-
📋
-
-

ملخّص نتائجك

-

تم تحليل {len(findings)} فحص طبي. - {'القيم التي تحتاج انتباهاً: ' + abnormal_names + '.' if abnormal else 'جميع القيم ضمن المعدل الطبيعي ✓'}

-
-
- """, unsafe_allow_html=True) - - st.markdown('

🔬 نتائج المختبر

', unsafe_allow_html=True) - cols = st.columns(4) - for i, (name, val, range_val, unit) in enumerate(findings): - if not is_valid_test(name): continue - status_class, badge_class, status_label = get_status_color(val, range_val) - with cols[i % 4]: - st.markdown(f""" -
-
{str(name).strip().upper()}
-
{val} {unit}
- {status_label} -
📏 المعدل: {range_val}
-
- """, unsafe_allow_html=True) - - st.markdown("
", unsafe_allow_html=True) - st.markdown('

📖 التقرير التحليلي المدعوم بالمراجع

', unsafe_allow_html=True) - - with st.spinner("جاري صياغة التقرير الطبي الموثق..."): - context = "" - try: - if os.path.exists(r'D:\Project\chroma_db'): - db_rag = Chroma(persist_directory=r'D:\Project\chroma_db', embedding_function=embeddings) - docs = db_rag.similarity_search(f"Clinical pathology: {', '.join([f[0] for f in findings])}", k=4) - context_list = [] - for idx, d in enumerate(docs, 1): - src = d.metadata.get('source','Unknown').split('\\')[-1] - pg = d.metadata.get('page','?') - context_list.append(f"[ref {idx}]: {src} p{pg}\n{d.page_content}") - print(f"ref: {src} (p{pg})") - context = "\n\n".join(context_list) - except Exception as e: - print(f"RAG error: {e}") - - site_prompt = ( - "أنت طبيب مختبر خبير. أرجع JSON فقط بدون أي نص خارجه. ابدأ بـ { مباشرة.\n" - "لا HTML ولا markdown. نصوص عربية بسيطة فقط.\n\n" - f"النتائج: {findings}\n" - f"المراجع: {context if context else 'لا توجد مراجع'}\n\n" - '{"تقييم_عام":"جملة أو جملتين عن الحالة العامة",' - '"قيم_غير_طبيعية":[{"اسم_الفحص":"اسم","النتيجة":"قيمة","المعدل_الطبيعي":"مدى","الحالة":"مرتفع او منخفض","الشرح":"شرح طبي","المرجع":"مرجع او لا يوجد"}],' - '"نصائح":["نصيحة1","نصيحة2","نصيحة3"]}' - ) - - try: - import json - raw = model.generate_content(site_prompt).text.strip() - raw = raw.replace("```json","").replace("```","").strip() - if '{' in raw: - raw = raw[raw.index('{'):raw.rindex('}')+1] - report_data = json.loads(raw) - - st.markdown(f""" -
-
-
📋
- التقييم العام للحالة -
-
-

{report_data.get("تقييم_عام","")}

-
- """, unsafe_allow_html=True) - - abnormal_items = report_data.get("قيم_غير_طبيعية", []) - if abnormal_items: - st.markdown('

🔬 تفصيل القيم غير الطبيعية

', unsafe_allow_html=True) - - for item in abnormal_items: - حالة = item.get("الحالة","") - if "مرتفع" in حالة: - dot_color="#EF4444"; badge_bg="#FEE2E2"; badge_color="#991B1B"; badge_text="↑ مرتفع"; bar_color="#EF4444"; bar_pct=85 - else: - dot_color="#F59E0B"; badge_bg="#FEF3C7"; badge_color="#92400E"; badge_text="↓ منخفض"; bar_color="#F59E0B"; bar_pct=18 - مرجع = item.get("المرجع","") - مرجع_html = f'
📖
{مرجع}
' if مرجع else "" - st.markdown(f""" -
-
-

{item.get("اسم_الفحص","")}

- - {badge_text} - -
-
-
القيمة {item.get("النتيجة","")}
-
المعدل {item.get("المعدل_الطبيعي","")}
-
-
-
-
-

{item.get("الشرح","")}

- {مرجع_html} -
- """, unsafe_allow_html=True) - - نصائح = report_data.get("نصائح", []) - if نصائح: - نصائح_html = "".join([f'
{n}
' for n in نصائح]) - st.markdown(f""" -
-
-
💡
- نصائح طبية مقترحة -
- {نصائح_html} -
- """, unsafe_allow_html=True) - - except Exception as e: - st.error("فشل التواصل مع Gemini") - print(f"ERROR: {e}") - - try: - import time - time.sleep(20) - audit_prompt = f"تقرير فجوات البيانات للمطورة: النتائج {findings}, المراجع {context[:500]}" - audit_resp = model.generate_content(audit_prompt) - print("\n=== DATA GAP REPORT ===") - print(audit_resp.text) - except Exception as e: - print(f"AUDIT ERROR: {e}") - - st.markdown(""" -
⚕️ إخلاء مسؤولية: هذا التقرير للتوعية الصحية فقط ولا يُعتبر بديلاً عن استشارة الطبيب المختص.
- """, unsafe_allow_html=True) - - else: - st.markdown(""" -
-
⚠️
-

لم يتم العثور على نتائج

-

تأكد أن الملف يحتوي على نتائج تحاليل طبية واضحة وحاول مرة أخرى.

-
- """, unsafe_allow_html=True) - - -# ===== CHATBOT ===== -from sklearn.metrics.pairwise import cosine_similarity - -@st.cache_resource -def load_chat_db(): - from langchain_community.vectorstores import Chroma as ChromaDB - emb = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2") - try: - db = ChromaDB(persist_directory=r'D:\Project\chroma_db', embedding_function=emb) - return emb, db - except: - return emb, None - -def is_medical(query): - words = ["ألم","صداع","تعب","دوخة","حمى","سعال","أعراض","ضغط","سكر","قلب","معدة","تنفس","التهاب","دواء","علاج","تحليل","دم","فيتامين","نتائج","فحص","تشخيص","كوليسترول","حديد","مختبر","هيموجلوبين","خلايا"] - return any(w in query for w in words) - -def run_chat(query): - if not is_medical(query): - return "هذا النظام مخصص للأسئلة الطبية فقط." - _, chat_db = load_chat_db() - context = "" - used_rag = False - if chat_db: - try: - results = chat_db.similarity_search_with_relevance_scores(query, k=3) - filtered = [doc for doc, score in results if score >= 0.8] - if filtered: - context = "\n\n".join([d.page_content for d in filtered]) - used_rag = True - except: - pass - prompt = f"""أنت مساعد طبي ذكي. - -أجب على السؤال الطبي التالي بشكل واضح ومبسط. - -إذا كانت الحالة مرتبطة بأعراض أو مشاكل صحية، يجب عليك في نهاية الإجابة إضافة قسم بعنوان: - -"التحاليل المقترحة (إن وجدت):" - -وتذكر التحاليل المناسبة التي قد يطلبها الطبيب حسب الحالة فقط (إن لزم الأمر). - -إذا لم تكن هناك حاجة لتحاليل، لا تذكر هذا القسم. - -{("السياق من المراجع:\n" + context) if context else ""} - -السؤال: -{query} -""" - try: - response = model.generate_content(prompt).text - if not used_rag: - response += "\n\n🤖 *هذه الإجابة من Gemini AI*" - return response - except Exception as e: - return f"حدث خطأ: {e}" - -# تهيئة الشات -if "chat_msgs" not in st.session_state: - st.session_state.chat_msgs = [{"role":"bot","text":"مرحباً! أنا مساعدك الطبي الذكي 🩺\nاسألني عن أي تحليل أو حالة صحية."}] -if "chat_open" not in st.session_state: - st.session_state.chat_open = False - -import html as html_lib -import streamlit.components.v1 as components - -if "chat_msgs" not in st.session_state: - st.session_state.chat_msgs = [{"role":"bot","text":"مرحباً! اسألني عن أي تحليل أو حالة صحية 🩺"}] - -# إخفاء الشات لما يكون فيه ملف مرفوع -if uploaded_file: - st.markdown("""""", unsafe_allow_html=True) - -def format_chat_text(text): - safe = html_lib.escape(str(text)) - safe = re.sub(r'\*\*(.+?)\*\*', r'\1', safe) - safe = re.sub(r'\*(.+?)\*', r'\1', safe) - lines = safe.split('\n') - result_lines = [] - in_list = False - for line in lines: - stripped = line.strip() - if re.match(r'^[-•]\s+.+', stripped): - if not in_list: - result_lines.append('
    ') - in_list = True - item_text = re.sub(r'^[-•]\s+', '', stripped) - result_lines.append(f'
  • {item_text}
  • ') - else: - if in_list: - result_lines.append('
') - in_list = False - result_lines.append(line) - if in_list: - result_lines.append('') - return '
'.join(result_lines) - -msgs_html = "" -for msg in st.session_state.chat_msgs: - txt = format_chat_text(msg["text"]) - if msg["role"] == "bot": - msgs_html += f'
{txt}
' - else: - msgs_html += f'
{txt}
' - -chat_html = f""" - - - - - - -
-
-
-
🩺 المساعد الطبي
-
مدعوم بالذكاء الاصطناعي
-
- -
-
{msgs_html}
-
- - -
-
- - -""" - -components.html(chat_html, height=520, scrolling=False) - -# st.chat_input شفاف - يشتغل بس مو مرئي -st.markdown("""""", unsafe_allow_html=True) - -user_q = st.chat_input("سؤال") -if user_q: - st.session_state.chat_msgs.append({"role":"user","text":user_q}) - try: - strong_words = ["ألم","صداع","تعب","دوخة","حمى","سعال","غثيان","قيء","أعراض","ضغط","سكر","قلب","معدة","تنفس","التهاب","دواء","علاج"] - weak_words = ["تحليل","دم","فيتامين","مختبر","نتائج","فحص","تشخيص","كوليسترول","حديد"] - score = sum(0.6 for w in strong_words if w in user_q) + sum(0.3 for w in weak_words if w in user_q) + 0.5 - if score < 0.8: - ans = "هذا النظام مخصص للأسئلة الطبية فقط." - else: - context = "" - try: - if os.path.exists(r'D:\Project\chroma_db'): - _db = Chroma(persist_directory=r'D:\Project\chroma_db', embedding_function=embeddings) - results = _db.similarity_search_with_relevance_scores(user_q, k=3) - filtered = [doc for doc, s in results if s >= 0.8] - if filtered: - context = "\n\n".join([d.page_content for d in filtered]) - except Exception as e: - print(f"RAG error: {e}") - prompt = f"""أنت مساعد طبي ذكي. - -أجب على السؤال الطبي التالي بشكل واضح ومبسط. - -إذا كانت الحالة مرتبطة بأعراض أو مشاكل صحية، يجب عليك في نهاية الإجابة إضافة قسم بعنوان: - -"التحاليل المقترحه:" - -وتذكر التحاليل المناسبة التي قد يطلبها الطبيب حسب الحالة فقط (إن لزم الأمر). - -إذا لم تكن هناك حاجة لتحاليل، لا تذكر هذا القسم. - -{("السياق من المراجع:\n" + context) if context else ""} - -السؤال: -{user_q}""" - ans = model.generate_content(prompt).text - except Exception as e: - ans = f"حدث خطأ: {e}" - st.session_state.chat_msgs.append({"role":"bot","text":ans}) - st.rerun() \ No newline at end of file diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..a5f9da37611fe1fd67ecb7c41f36308a199d7605 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,13 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.env +.env.* +chroma_db/ +model_cache/ +*.log +*.txt.bak +venv/ +.venv/ +tests/ diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..ac4c36a0071f12a0a8f6755531481e2e3ec3aa45 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,30 @@ +# ── Groq (required) ────────────────────────────────────── +GROQ_API_KEY=gsk_... + +# ── Cohere (required for reranking, fallback to BM25 if missing) ── +COHERE_API_KEY=... + +# ── Supabase (required) ─────────────────────────────────── +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_KEY=eyJ... # anon key (public — used for pgvector KB search) +SUPABASE_SERVICE_KEY=eyJ... # service_role key (secret — used for analyses/chat tables) +SUPABASE_DB_URL=postgresql://postgres:password@db.your-project.supabase.co:5432/postgres + +# ── Google Cloud Vision OCR (required for image OCR) ────── +GOOGLE_VISION_API_KEY=AIza... + +# ── Frontend URL (CORS whitelist) ───────────────────────── +FRONTEND_URL=https://your-app.vercel.app + +# ── Supabase Auth (required for protected API endpoints) ── +# Find in: Supabase Dashboard → Project Settings → API → JWT Secret +SUPABASE_JWT_SECRET=your-supabase-jwt-secret + +# ── Environment ─────────────────────────────────────────── +ENVIRONMENT=production + +# ── Sentry (optional) ───────────────────────────────────── +SENTRY_DSN=https://...@sentry.io/... + +# ── HuggingFace (optional — for higher rate limits) ─────── +HF_TOKEN=hf_... diff --git a/backend/Dockerfile b/backend/Dockerfile index 107d08625874261afe3915286c33be57fe2c3a06..7c328bb824671be51ade395f33d8872614874fcf 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,23 +2,34 @@ FROM python:3.11-slim WORKDIR /app -# نظام الاعتمادات -RUN apt-get update && apt-get install -y \ - libgl1-mesa-glx \ +# System dependencies for OpenCV / EasyOCR +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgl1 \ libglib2.0-0 \ libsm6 \ libxext6 \ - libxrender-dev \ + libxrender1 \ && rm -rf /var/lib/apt/lists/* +# Install CPU-only torch first (avoids pulling 2 GB of CUDA packages) +RUN pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu + +# Install remaining Python dependencies COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +# Pre-download the embedding model so first request is instant +# Model is cached in /app/model_cache (bind-mount a volume in prod for persistence) +ENV HF_HOME=/app/model_cache +RUN python -c "\ +from langchain_huggingface import HuggingFaceEmbeddings; \ +HuggingFaceEmbeddings(model_name='intfloat/multilingual-e5-large'); \ +print('Model cached ✓')" + COPY . . -ENV HF_HOME=/app/model_cache ENV PORT=8000 EXPOSE 8000 -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["python", "run.py"] diff --git a/backend/evaluate.py b/backend/evaluate.py index 007ecaec198c515f2f1b8ac1d9fce62ab07ffc29..015e01f785820454a15b6b2bfe56e7e9592ca82b 100644 --- a/backend/evaluate.py +++ b/backend/evaluate.py @@ -1,22 +1,23 @@ """ M7 — RAGAS-light: تقييم جودة الـ RAG بدون OpenAI -يستخدم Groq بدلاً منه للحفاظ على المجانية +يستخدم Groq بدلاً منه — pgvector + SemanticSearchService """ import os import json from dotenv import load_dotenv load_dotenv(dotenv_path=r'D:\Project\.env') -os.environ['HF_HOME'] = r'D:\Project\model_cache' +os.environ.setdefault('HF_HOME', r'D:\Project\model_cache') os.environ['TRANSFORMERS_VERBOSITY'] = 'error' from groq import Groq -from langchain_huggingface import HuggingFaceEmbeddings -from langchain_chroma import Chroma +from services.search.semantic_search import SemanticSearchService +from services.rag.retriever import Retriever, RetrievalConfig +from services.rag.context_builder import build_context -GROQ_API_KEY = os.getenv("GROQ_API_KEY") -EMBED_MODEL = "intfloat/multilingual-e5-large" -GROQ_MODEL = "llama-3.1-8b-instant" -DB_PATH = r'D:\Project\chroma_db' +GROQ_API_KEY = os.getenv("GROQ_API_KEY") +SUPABASE_URL = os.getenv("SUPABASE_URL") +SUPABASE_KEY = os.getenv("SUPABASE_KEY") +GROQ_MODEL = "llama-3.1-8b-instant" TEST_QUESTIONS = [ {"q": "ما هي القيم الطبيعية للهيموجلوبين؟", "ref": "رجال 13.5-17.5 g/dL نساء 12-15.5 g/dL"}, @@ -24,35 +25,36 @@ TEST_QUESTIONS = [ {"q": "ما أعراض نقص فيتامين د؟", "ref": "آلام العظام وضعف العضلات وضعف المناعة"}, {"q": "كيف أخفض سكر الدم طبيعياً؟", "ref": "الرياضة والغذاء الصحي وتقليل النشويات"}, {"q": "ما سبب ارتفاع إنزيمات الكبد ALT AST؟", "ref": "التهاب الكبد الكبد الدهني الكحول"}, - {"q": "ما القيم الطبيعية للكرياتينين؟", "ref": "رجال 0.74-1.35 نساء 0.59-1.04 mg/dL"}, + {"q": "ما القيم الطبيعية للكرياتينين؟", "ref": "رجال 0.7-1.3 نساء 0.5-1.1 mg/dL"}, {"q": "ما أسباب انخفاض خلايا الدم البيضاء؟", "ref": "أمراض المناعة العلاج الكيميائي أمراض النخاع"}, {"q": "ما معنى ارتفاع TSH؟", "ref": "قصور الغدة الدرقية الغدة خاملة"}, + {"q": "ما أسباب ارتفاع الدهون الثلاثية؟", "ref": "السكر والكربوهيدرات والسمنة والخمول"}, + {"q": "ما معنى انخفاض eGFR؟", "ref": "ضعف وظيفة الكلى ومرض الكلى المزمن"}, ] -def get_rag_answer(client, db, question: str) -> tuple[str, str]: - results = db.similarity_search_with_relevance_scores(question, k=5) - filtered = [(d, s) for d, s in results if s >= 0.4] - context = "\n\n".join([d.page_content for d, _ in filtered[:3]]) +def get_rag_answer(groq_client: Groq, retriever: Retriever, question: str) -> tuple[str, str]: + results, _ = retriever.retrieve(question, RetrievalConfig(k=8, top_n=4, use_multi_query=False)) + context = build_context(results, max_tokens=1200) messages = [ - {"role": "system", "content": "أنت مساعد طبي. أجب باختصار بالعربية مستنداً للسياق."}, + {"role": "system", "content": "أنت مساعد طبي دقيق. أجب باختصار بالعربية مستنداً فقط للسياق المعطى."}, {"role": "user", "content": f"السياق:\n{context}\n\nالسؤال: {question}"}, ] - r = client.chat.completions.create( + r = groq_client.chat.completions.create( model=GROQ_MODEL, messages=messages, temperature=0.1, max_tokens=300 ) return r.choices[0].message.content, context -def evaluate_single(client, answer: str, context: str, question: str, reference: str) -> dict: +def evaluate_single(groq_client: Groq, answer: str, context: str, question: str, reference: str) -> dict: prompt = f"""قيّم الإجابة التالية بدقة. أجب بـ JSON فقط بهذا الشكل: {{"faithfulness": X, "answer_relevance": X, "context_precision": X, "notes": "..."}} -حيث القيم من 0.0 إلى 1.0: -- faithfulness: هل الإجابة مبنية على السياق المعطى؟ -- answer_relevance: هل الإجابة تجيب على السؤال؟ -- context_precision: هل السياق يحتوي على معلومات مفيدة؟ +القيم من 0.0 إلى 1.0: +- faithfulness: هل الإجابة مبنية فعلاً على السياق المعطى؟ +- answer_relevance: هل الإجابة تجيب على السؤال بشكل مباشر؟ +- context_precision: هل السياق يحتوي معلومات مفيدة للسؤال؟ السؤال: {question} المرجع: {reference} @@ -60,7 +62,7 @@ def evaluate_single(client, answer: str, context: str, question: str, reference: الإجابة: {answer[:500]}""" try: - r = client.chat.completions.create( + r = groq_client.chat.completions.create( model=GROQ_MODEL, messages=[{"role": "user", "content": prompt}], temperature=0, max_tokens=200, @@ -75,46 +77,65 @@ def evaluate_single(client, answer: str, context: str, question: str, reference: def run_evaluation(): print("=" * 60) - print("M7 — RAGAS-light Evaluation") + print("RAGAS-light Evaluation | pgvector + Groq") print("=" * 60) - client = Groq(api_key=GROQ_API_KEY) - embed = HuggingFaceEmbeddings(model_name=EMBED_MODEL) - db = Chroma(persist_directory=DB_PATH, embedding_function=embed) - print(f"[DB] {db._collection.count()} chunks loaded\n") + groq_client = Groq(api_key=GROQ_API_KEY) + search_svc = SemanticSearchService(SUPABASE_URL, SUPABASE_KEY) + retriever = Retriever(search_svc) - results = [] - totals = {"faithfulness": 0, "answer_relevance": 0, "context_precision": 0} + total_chunks = search_svc.count() + print(f"[DB] {total_chunks} chunks in pgvector\n") + + results = [] + totals = {"faithfulness": 0.0, "answer_relevance": 0.0, "context_precision": 0.0} for i, item in enumerate(TEST_QUESTIONS, 1): - print(f"[{i}/{len(TEST_QUESTIONS)}] {item['q'][:50]}...") - answer, context = get_rag_answer(client, db, item["q"]) - metrics = evaluate_single(client, answer, context, item["q"], item["ref"]) + print(f"[{i}/{len(TEST_QUESTIONS)}] {item['q'][:55]}...") + answer, context = get_rag_answer(groq_client, retriever, item["q"]) + metrics = evaluate_single(groq_client, answer, context, item["q"], item["ref"]) for k in totals: totals[k] += metrics.get(k, 0) results.append({ "question": item["q"], - "answer": answer[:150], + "answer": answer[:200], "metrics": metrics, }) print(f" faithfulness={metrics.get('faithfulness', 0):.2f} " f"| relevance={metrics.get('answer_relevance', 0):.2f} " - f"| precision={metrics.get('context_precision', 0):.2f}") + f"| precision={metrics.get('context_precision', 0):.2f}" + f" — {metrics.get('notes', '')[:60]}") n = len(TEST_QUESTIONS) + avg_f = totals['faithfulness'] / n + avg_r = totals['answer_relevance'] / n + avg_p = totals['context_precision'] / n + overall = (avg_f + avg_r + avg_p) / 3 + print("\n" + "=" * 60) print("النتائج الكلية:") - print(f" Faithfulness (أمانة الإجابة): {totals['faithfulness']/n:.2%}") - print(f" Answer Relevance (صلة الإجابة): {totals['answer_relevance']/n:.2%}") - print(f" Context Precision (دقة السياق): {totals['context_precision']/n:.2%}") - print(f" المتوسط العام: {sum(totals.values())/(n*3):.2%}") + print(f" Faithfulness (امانة الإجابة): {avg_f:.1%}") + print(f" Answer Relevance (صلة الإجابة): {avg_r:.1%}") + print(f" Context Precision (دقة السياق): {avg_p:.1%}") + print(f" المتوسط العام: {overall:.1%}") print("=" * 60) + summary = { + "faithfulness": round(avg_f, 3), + "answer_relevance": round(avg_r, 3), + "context_precision":round(avg_p, 3), + "overall": round(overall, 3), + "total_chunks": total_chunks, + "questions_tested": n, + } + output = {"summary": summary, "details": results} + with open("eval_results.json", "w", encoding="utf-8") as f: - json.dump({"summary": {k: round(v/n, 3) for k, v in totals.items()}, "details": results}, f, ensure_ascii=False, indent=2) - print("\nحُفظت النتائج في eval_results.json") + json.dump(output, f, ensure_ascii=False, indent=2) + print("Results saved to eval_results.json") + return summary if __name__ == "__main__": diff --git a/backend/ingest_full.py b/backend/ingest_full.py deleted file mode 100644 index 5c12765dfc854308db92eee9a7d6ee9e8671551b..0000000000000000000000000000000000000000 --- a/backend/ingest_full.py +++ /dev/null @@ -1,1361 +0,0 @@ -""" -بناء الموسوعة الطبية الكاملة: -1. تنظيف التكرار من ChromaDB -2. تعاريف التحاليل (عربي + إنجليزي) -3. معلومات شاملة عن التحاليل -4. توصيات صحية -5. معلومات طبية عامة من MedlinePlus -""" -import os -import re -import time -import requests -import xml.etree.ElementTree as ET - -os.environ['HF_HOME'] = r'D:\Project\model_cache' -os.environ['TRANSFORMERS_VERBOSITY'] = 'error' - -from langchain_huggingface import HuggingFaceEmbeddings -from langchain_chroma import Chroma -from langchain_core.documents import Document - -DB_PATH = r'D:\Project\chroma_db' -EMBEDDINGS_MODEL = "intfloat/multilingual-e5-large" - -# ============================================================ -# القسم 1: تعاريف التحاليل (عربي + إنجليزي) -# ============================================================ -LAB_DEFINITIONS = [ - { - "name_ar": "هيموجلوبين", "name_en": "Hemoglobin (HGB)", - "definition": "بروتين في خلايا الدم الحمراء يحمل الأكسجين من الرئتين لأنسجة الجسم. القيم الطبيعية: رجال 13.5-17.5 g/dL، نساء 12-15.5 g/dL.", - "high": "كثرة الحمر، الجفاف، أمراض الرئة المزمنة، ارتفاع الألتيتيود.", - "low": "فقر الدم (الأنيميا)، نزيف داخلي، نقص الحديد أو فيتامين B12 أو حمض الفوليك.", - "symptoms_low": "تعب، شحوب، ضيق تنفس، دوخة، خفقان قلب.", - }, - { - "name_ar": "خلايا الدم الحمراء", "name_en": "Red Blood Cells (RBC)", - "definition": "عدد خلايا الدم الحمراء في الدم. القيم الطبيعية: رجال 4.5-5.9 مليون/µL، نساء 4.1-5.1 مليون/µL.", - "high": "كثرة الحمر، الجفاف، أمراض القلب الخلقية.", - "low": "فقر الدم، نزيف، فشل كلوي، نقص غذائي.", - "symptoms_low": "إرهاق، شحوب، ضعف عام.", - }, - { - "name_ar": "خلايا الدم البيضاء", "name_en": "White Blood Cells (WBC)", - "definition": "خلايا الجهاز المناعي التي تحارب العدوى. القيم الطبيعية: 4,000-11,000 خلية/µL.", - "high": "عدوى بكتيرية، التهاب، أمراض الدم كاللوكيميا.", - "low": "أمراض المناعة، العلاج الكيميائي، أمراض النخاع العظمي.", - "symptoms_low": "تكرار الإصابة بالعدوى، حمى متكررة.", - }, - { - "name_ar": "الصفائح الدموية", "name_en": "Platelets (PLT)", - "definition": "خلايا مسؤولة عن تخثر الدم وإيقاف النزيف. القيم الطبيعية: 150,000-400,000/µL.", - "high": "التهاب، نزيف، نقص الحديد، خطر تجلط الدم.", - "low": "نزيف تلقائي، أمراض الكبد، الذئبة، العلاج الكيميائي.", - "symptoms_low": "كدمات سهلة، نزيف اللثة، نزيف طويل عند الجرح.", - }, - { - "name_ar": "سكر الدم الصائم", "name_en": "Fasting Blood Glucose", - "definition": "مستوى السكر في الدم بعد 8 ساعات صيام. القيم الطبيعية: 70-100 mg/dL. مرحلة ما قبل السكري: 100-125. سكري: 126 فما فوق.", - "high": "السكري، مقاومة الأنسولين، الإجهاد، بعض الأدوية.", - "low": "انخفاض السكر (نقص سكر الدم)، الصيام الطويل، جرعة زائدة من الأنسولين.", - "symptoms_low": "رعشة، تعرق، دوخة، فقدان وعي في الحالات الشديدة.", - }, - { - "name_ar": "الهيموجلوبين الغليكوزيلاتي", "name_en": "HbA1c (Glycated Hemoglobin)", - "definition": "يقيس متوسط سكر الدم خلال 3 أشهر. طبيعي: أقل من 5.7%. ما قبل السكري: 5.7-6.4%. سكري: 6.5% فأكثر.", - "high": "سوء ضبط السكري، خطر مضاعفات السكري.", - "low": "ضبط جيد للسكري، أو فقر الدم الانحلالي (يعطي قراءة خاطئة).", - "symptoms_low": "نادراً ما يسبب أعراضاً بحد ذاته.", - }, - { - "name_ar": "الكوليسترول الكلي", "name_en": "Total Cholesterol", - "definition": "إجمالي الدهون في الدم. المثالي: أقل من 200 mg/dL. حدي: 200-239. مرتفع: 240 فأكثر.", - "high": "خطر أمراض القلب والشرايين، السكتة الدماغية.", - "low": "نادر، قد يرتبط بسوء التغذية أو مشاكل الكبد.", - "symptoms_low": "الكوليسترول المرتفع لا يسبب أعراضاً واضحة.", - }, - { - "name_ar": "الكوليسترول الضار", "name_en": "LDL Cholesterol (Bad Cholesterol)", - "definition": "الكوليسترول منخفض الكثافة - يترسب في الشرايين. المثالي: أقل من 100 mg/dL. مرتفع: 160 فأكثر.", - "high": "تصلب الشرايين، أمراض القلب، جلطات.", - "low": "مثالي، يقلل خطر أمراض القلب.", - "symptoms_low": "لا أعراض.", - }, - { - "name_ar": "الكوليسترول النافع", "name_en": "HDL Cholesterol (Good Cholesterol)", - "definition": "الكوليسترول عالي الكثافة - يزيل الكوليسترول من الشرايين. المثالي: فوق 60 mg/dL. منخفض (خطر): أقل من 40 للرجال، 50 للنساء.", - "high": "حماية من أمراض القلب.", - "low": "خطر أمراض القلب، قلة الرياضة، التدخين، السمنة.", - "symptoms_low": "لا أعراض مباشرة لكن خطر قلبي مرتفع.", - }, - { - "name_ar": "الدهون الثلاثية", "name_en": "Triglycerides", - "definition": "نوع من الدهون في الدم. طبيعي: أقل من 150 mg/dL. حدي: 150-199. مرتفع: 200-499. مرتفع جداً: 500+.", - "high": "السمنة، السكري، الكحول، قصور الغدة الدرقية.", - "low": "نادر، طبيعي أو نقص غذائي.", - "symptoms_low": "الارتفاع الشديد يسبب التهاب البنكرياس.", - }, - { - "name_ar": "هرمون الغدة الدرقية TSH", "name_en": "Thyroid Stimulating Hormone (TSH)", - "definition": "يتحكم في نشاط الغدة الدرقية. القيم الطبيعية: 0.4-4.0 mIU/L.", - "high": "قصور الغدة الدرقية (الغدة خاملة).", - "low": "فرط نشاط الغدة الدرقية.", - "symptoms_low": "فرط النشاط: خفقان، فقدان وزن، تعرق، قلق. القصور: تعب، زيادة وزن، برد دائم.", - }, - { - "name_ar": "فيتامين د", "name_en": "Vitamin D (25-OH)", - "definition": "فيتامين ضروري لصحة العظام والمناعة. طبيعي: 30-100 ng/mL. نقص: 20-29. نقص شديد: أقل من 20.", - "high": "جرعة زائدة من المكملات (نادر من الشمس).", - "low": "قلة التعرض للشمس، سوء الامتصاص، السمنة.", - "symptoms_low": "آلام العظام، ضعف العضلات، تكرار الكسور، ضعف المناعة، اكتئاب.", - }, - { - "name_ar": "فيتامين ب12", "name_en": "Vitamin B12 (Cobalamin)", - "definition": "ضروري لصحة الأعصاب وتكوين الدم. طبيعي: 200-900 pg/mL. نقص: أقل من 200.", - "high": "نادر، قد يكون من مكملات زائدة.", - "low": "النباتيون، كبار السن، أمراض المعدة، الأدوية كالميتفورمين.", - "symptoms_low": "تنميل، وخز، فقر الدم، ضعف الذاكرة، إجهاد.", - }, - { - "name_ar": "الحديد في الدم", "name_en": "Serum Iron", - "definition": "مستوى الحديد في الدم. القيم الطبيعية: رجال 65-176 µg/dL، نساء 50-170 µg/dL.", - "high": "داء ترسب الأصبغة الدموية، نقل دم متعدد.", - "low": "نقص الحديد من سوء التغذية أو النزيف.", - "symptoms_low": "تعب، شحوب، هشاشة أظافر، تساقط شعر، صعوبة التركيز.", - }, - { - "name_ar": "الفيريتين", "name_en": "Ferritin", - "definition": "بروتين يخزن الحديد. أفضل مؤشر لمخزون الحديد. طبيعي: رجال 12-300 ng/mL، نساء 12-150 ng/mL.", - "high": "التهاب، أمراض الكبد، داء ترسب الأصبغة.", - "low": "نضوب مخزون الحديد (أول علامة لنقص الحديد قبل فقر الدم).", - "symptoms_low": "تعب مزمن، تساقط شعر، برودة الأطراف.", - }, - { - "name_ar": "حمض اليوريك", "name_en": "Uric Acid", - "definition": "ناتج تكسير البيورينات. طبيعي: رجال 3.5-7.2 mg/dL، نساء 2.6-6.0 mg/dL.", - "high": "النقرس، الفشل الكلوي، أكل كثير من اللحوم، السمنة.", - "low": "نادر، قد يكون من بعض الأدوية.", - "symptoms_low": "الارتفاع يسبب: ألم حاد في المفاصل (خاصة إصبع القدم الكبير)، حصوات كلى.", - }, - { - "name_ar": "الكرياتينين", "name_en": "Creatinine", - "definition": "مؤشر لوظائف الكلى. طبيعي: رجال 0.74-1.35 mg/dL، نساء 0.59-1.04 mg/dL.", - "high": "ضعف وظائف الكلى، الجفاف، زيادة كتلة العضلات.", - "low": "ضمور العضلات، سوء التغذية.", - "symptoms_low": "ارتفاع الكرياتينين: تورم، تعب، غثيان، قلة التبول.", - }, - { - "name_ar": "إنزيمات الكبد ALT AST", "name_en": "Liver Enzymes (ALT/AST)", - "definition": "مؤشرات لصحة الكبد. ALT طبيعي: 7-56 U/L. AST طبيعي: 10-40 U/L.", - "high": "التهاب الكبد، الكبد الدهني، الكحول، بعض الأدوية.", - "low": "لا أهمية سريرية.", - "symptoms_low": "ارتفاع الإنزيمات: يرقان، تعب، ألم أعلى البطن، غثيان.", - }, - { - "name_ar": "بروتين سي التفاعلي", "name_en": "C-Reactive Protein (CRP)", - "definition": "مؤشر للالتهاب في الجسم. طبيعي: أقل من 10 mg/L. مرتفع high-sensitivity CRP: فوق 3 mg/L خطر قلبي.", - "high": "عدوى، التهاب مفاصل، أمراض مناعية، خطر قلبي.", - "low": "غياب التهاب (جيد).", - "symptoms_low": "الارتفاع يرافقه أعراض المرض الأصلي.", - }, - { - "name_ar": "الحمضات (الإيوزينوفيل)", "name_en": "Eosinophils", - "definition": "نوع من خلايا الدم البيضاء. طبيعي: 1-4% من WBC أو 100-400 خلية/µL.", - "high": "الحساسية، الربو، الطفيليات، بعض الأمراض المناعية.", - "low": "ليس ذا أهمية سريرية.", - "symptoms_low": "الارتفاع يسبب: حساسية مستمرة، طفح جلدي، أعراض ربو.", - }, - { - "name_ar": "معدل ترسيب كريات الدم الحمراء", "name_en": "ESR (Erythrocyte Sedimentation Rate)", - "definition": "مؤشر التهاب غير محدد. طبيعي: رجال أقل من 15 mm/hr، نساء أقل من 20 mm/hr.", - "high": "التهابات، أمراض مناعية، سرطان، فقر الدم.", - "low": "فشل قلبي احتقاني، كثرة الحمر.", - "symptoms_low": "لا أعراض مباشرة، يُفسر مع الفحوصات الأخرى.", - }, - { - "name_ar": "الإنزيم القلبي تروبونين", "name_en": "Troponin (Cardiac)", - "definition": "مؤشر تلف عضلة القلب. طبيعي: أقل من 0.04 ng/mL (يختلف حسب المختبر).", - "high": "نوبة قلبية، التهاب عضلة القلب، الجلطة الرئوية.", - "low": "طبيعي.", - "symptoms_low": "الارتفاع يرافق: ألم صدر، ضيق تنفس، تعرق.", - }, - { - "name_ar": "هرمون الكورتيزول", "name_en": "Cortisol", - "definition": "هرمون التوتر من الغدة الكظرية. طبيعي صباحاً: 6-23 µg/dL.", - "high": "متلازمة كوشينغ، توتر شديد، ورم الغدة.", - "low": "قصور الغدة الكظرية (مرض أديسون).", - "symptoms_low": "الارتفاع: سمنة بطنية، ضغط مرتفع، سكر. الانخفاض: إجهاد، غثيان، انخفاض ضغط.", - }, - { - "name_ar": "هرمون الإنسولين", "name_en": "Insulin (Fasting)", - "definition": "هرمون يتحكم في السكر. طبيعي صائم: 2-25 µIU/mL.", - "high": "مقاومة الأنسولين، السمنة، ما قبل السكري، ورم الأنسولين.", - "low": "السكري النوع الأول، البنكرياس الضعيف.", - "symptoms_low": "ارتفاع الأنسولين مع سكر طبيعي = مقاومة الأنسولين.", - }, - { - "name_ar": "هرمون التستوستيرون", "name_en": "Testosterone (Total)", - "definition": "الهرمون الذكري الرئيسي. طبيعي للرجال: 300-1000 ng/dL، نساء: 15-70 ng/dL.", - "high": "أورام الغدة الكظرية، استخدام الستيرويدات.", - "low": "قصور الغدد التناسلية، السمنة، الشيخوخة، التوتر الشديد.", - "symptoms_low": "الانخفاض عند الرجال: ضعف جنسي، تعب، فقدان كتلة عضلية، اكتئاب.", - }, - { - "name_ar": "هرمون البرولاكتين", "name_en": "Prolactin", - "definition": "هرمون الحليب من الغدة النخامية. طبيعي: رجال 2-18 ng/mL، نساء غير حامل 2-29 ng/mL.", - "high": "ورم النخامية (البرولاكتينوما)، بعض الأدوية، قصور الغدة الدرقية.", - "low": "نادر، قد يكون من قصور النخامية.", - "symptoms_low": "الارتفاع: إفراز حليب، اضطراب دورة، ضعف جنسي.", - }, - { - "name_ar": "هرمون الإستروجين", "name_en": "Estradiol (E2)", - "definition": "الهرمون الأنثوي الرئيسي. يتغير حسب مرحلة الدورة.", - "high": "أورام المبيض، السمنة، أمراض الكبد.", - "low": "انقطاع الطمث، قصور المبيض، سوء التغذية.", - "symptoms_low": "الانخفاض: جفاف مهبلي، هشاشة عظام، اضطراب مزاج، هبات حرارة.", - }, - { - "name_ar": "زمن البروثرومبين", "name_en": "PT/INR (Prothrombin Time)", - "definition": "يقيس سرعة تخثر الدم. INR طبيعي: 0.8-1.2. مرضى الوارفارين: 2-3.", - "high": "نزيف، أمراض الكبد، نقص فيتامين K.", - "low": "خطر تجلط.", - "symptoms_low": "الارتفاع: نزيف سهل، كدمات، نزيف طويل.", - }, - { - "name_ar": "الأميليز والليباز", "name_en": "Amylase & Lipase", - "definition": "إنزيمات البنكرياس. أميليز طبيعي: 30-110 U/L. ليباز طبيعي: 0-160 U/L.", - "high": "التهاب البنكرياس الحاد، حصى المرارة، الكحول.", - "low": "لا أهمية سريرية.", - "symptoms_low": "الارتفاع الشديد: ألم بطن حاد، غثيان، قيء.", - }, - { - "name_ar": "تحليل البول الكامل", "name_en": "Urinalysis (Complete)", - "definition": "فحص البول للكشف عن أمراض الكلى والمسالك البولية والسكري.", - "high": "بروتين في البول: مشكلة كلى. سكر في البول: سكري. دم في البول: التهاب أو حصوات.", - "low": "بول طبيعي: شفاف، أصفر فاتح، بدون بروتين أو سكر أو دم.", - "symptoms_low": "البول الغائم أو الداكن أو ذو رائحة شديدة يستوجب فحصاً.", - }, - { - "name_ar": "معدل الترشيح الكبيبي", "name_en": "eGFR (Glomerular Filtration Rate)", - "definition": "أفضل مقياس لوظيفة الكلى. طبيعي: فوق 90 mL/min. مرحلة الفشل: أقل من 15.", - "high": "غير ذي أهمية عند الارتفاع.", - "low": "مرض كلوي مزمن بدرجات متفاوتة.", - "symptoms_low": "GFR 30-59: مرحلة متوسطة. GFR 15-29: مرحلة متقدمة. أقل من 15: فشل كلوي.", - }, - { - "name_ar": "حمض الفوليك", "name_en": "Folic Acid (Folate)", - "definition": "فيتامين B9 ضروري لتكوين الدم وتطور الجنين. طبيعي: 2-20 ng/mL.", - "high": "من المكملات (لا ضرر).", - "low": "سوء التغذية، الحمل، الكحول، أمراض الأمعاء.", - "symptoms_low": "فقر الدم الضخم الكريات، تشوهات الجنين (أنبوب عصبي)، تعب.", - }, - { - "name_ar": "الزنك", "name_en": "Zinc", - "definition": "معدن ضروري للمناعة والجرح والتكاثر. طبيعي: 60-120 µg/dL.", - "high": "تسمم نادر من المكملات الزائدة.", - "low": "سوء التغذية، أمراض الأمعاء، السكري.", - "symptoms_low": "ضعف المناعة، بطء التئام الجروح، فقدان الشم، تساقط الشعر.", - }, - { - "name_ar": "المغنيسيوم", "name_en": "Magnesium", - "definition": "معدن حيوي لأكثر من 300 وظيفة في الجسم. طبيعي: 1.7-2.2 mg/dL.", - "high": "الفشل الكلوي، جرعة زائدة من المكملات.", - "low": "سوء التغذية، السكري، الكحول، بعض الأدوية.", - "symptoms_low": "تشنجات عضلية، رعشة، صداع، قلق، عدم انتظام القلب.", - }, - { - "name_ar": "الألبومين", "name_en": "Albumin", - "definition": "البروتين الرئيسي في الدم، يعكس التغذية ووظيفة الكبد. طبيعي: 3.5-5.0 g/dL.", - "high": "نادر، الجفاف.", - "low": "سوء التغذية، أمراض الكبد، الكلى، الالتهابات المزمنة.", - "symptoms_low": "تورم (وذمة)، ضعف عام، بطء التئام الجروح.", - }, - { - "name_ar": "البيليروبين", "name_en": "Bilirubin (Total/Direct)", - "definition": "ناتج تكسير خلايا الدم الحمراء. طبيعي الكلي: 0.2-1.2 mg/dL.", - "high": "يرقان، أمراض الكبد، انسداد القنوات الصفراوية، فقر الدم الانحلالي.", - "low": "لا أهمية سريرية.", - "symptoms_low": "الارتفاع يسبب: اصفرار الجلد والعينين، بول داكن، براز فاتح.", - }, - { - "name_ar": "الفوسفور", "name_en": "Phosphorus", - "definition": "معدن ضروري للعظام والطاقة. طبيعي: 2.5-4.5 mg/dL.", - "high": "الفشل الكلوي، قصور الدريقات.", - "low": "سوء التغذية، مضادات الحموضة، فرط الدريقات.", - "symptoms_low": "ضعف عضلي، آلام عظام، تشوش ذهني.", - }, - { - "name_ar": "البروتين الكلي", "name_en": "Total Protein", - "definition": "مجموع البروتينات في الدم (ألبومين + جلوبيولين). طبيعي: 6.3-8.2 g/dL.", - "high": "الجفاف، أمراض المناعة، بعض السرطانات.", - "low": "سوء التغذية، أمراض الكبد والكلى.", - "symptoms_low": "ضعف، تورم، ضعف مناعة.", - }, - # CBC إضافي - { - "name_ar": "الهيماتوكريت", "name_en": "Hematocrit (HCT)", - "definition": "نسبة حجم كريات الدم الحمراء من إجمالي الدم. طبيعي: رجال 38.3-48.6%، نساء 35.5-44.9%.", - "high": "الجفاف، كثرة الحمر، أمراض الرئة المزمنة.", - "low": "فقر الدم، فقدان الدم، الحمل.", - "symptoms_low": "تعب، ضيق تنفس، شحوب.", - }, - { - "name_ar": "متوسط حجم الكرية الحمراء", "name_en": "Mean Corpuscular Volume (MCV)", - "definition": "متوسط حجم كريات الدم الحمراء. طبيعي: 80-100 fL. يساعد في تصنيف نوع فقر الدم.", - "high": "فقر الدم الضخم الكريات (نقص B12 أو فولات)، الكحول، قصور الدرقية.", - "low": "فقر الدم الصغير الكريات (نقص حديد، ثلاسيميا).", - "symptoms_low": "أعراض فقر الدم مع تعب وشحوب.", - }, - { - "name_ar": "متوسط كمية الهيموجلوبين", "name_en": "MCH (Mean Corpuscular Hemoglobin)", - "definition": "كمية الهيموجلوبين في كل كرية حمراء. طبيعي: 27-33 pg.", - "high": "فقر الدم الضخم الكريات.", - "low": "فقر الدم الصغير الكريات، نقص الحديد.", - "symptoms_low": "يُفسر مع MCV وهيموجلوبين لتصنيف فقر الدم.", - }, - { - "name_ar": "تركيز الهيموجلوبين في الكرية", "name_en": "MCHC (Mean Corpuscular Hemoglobin Concentration)", - "definition": "تركيز الهيموجلوبين في كل كرية. طبيعي: 31.5-36 g/dL.", - "high": "فقر الدم الانحلالي الوراثي، الجفاف.", - "low": "نقص الحديد، الثلاسيميا.", - "symptoms_low": "أعراض فقر الدم.", - }, - { - "name_ar": "تباين حجم الكريات", "name_en": "RDW (Red Cell Distribution Width)", - "definition": "مقياس اختلاف أحجام كريات الدم الحمراء. طبيعي: 11.5-14.5%.", - "high": "نقص الحديد، B12، فولات، فقر الدم الانحلالي.", - "low": "غير ذي أهمية سريرية عادة.", - "symptoms_low": "يُستخدم مع MCV لتصنيف نوع فقر الدم بدقة.", - }, - { - "name_ar": "العدلات", "name_en": "Neutrophils", - "definition": "أكثر خلايا الدم البيضاء شيوعاً. الخط الأول ضد البكتيريا. طبيعي: 40-70% من WBC أو 1800-7800/µL.", - "high": "عدوى بكتيرية، التهاب، توتر، كورتيكوستيرويدات.", - "low": "عدوى فيروسية شديدة، أدوية، أمراض نخاع العظم.", - "symptoms_low": "خطر عالٍ للعدوى البكتيرية عند الانخفاض الشديد.", - }, - { - "name_ar": "الخلايا اللمفاوية", "name_en": "Lymphocytes", - "definition": "تنتج الأجسام المضادة وتحارب الفيروسات. طبيعي: 20-40% من WBC.", - "high": "عدوى فيروسية (كورونا، مونو)، ابيضاض اللمفاوية.", - "low": "HIV، الكورتيكوستيرويدات، العلاج الإشعاعي، التوتر الشديد.", - "symptoms_low": "ضعف المناعة ضد الفيروسات.", - }, - { - "name_ar": "الوحيدات", "name_en": "Monocytes", - "definition": "تلتهم الجراثيم والخلايا الميتة. طبيعي: 2-8% من WBC.", - "high": "عدوى مزمنة، التهاب، السل، أمراض مناعية.", - "low": "نادر، قد يكون من الكورتيكوستيرويدات.", - "symptoms_low": "يُفسر مع بقية خلايا الدم البيضاء.", - }, - { - "name_ar": "القعدات", "name_en": "Basophils", - "definition": "أندر خلايا الدم البيضاء، مرتبطة بالحساسية. طبيعي: 0.5-1% من WBC.", - "high": "أمراض الدم، الحساسية المزمنة، قصور الدرقية.", - "low": "نادراً ما له أهمية سريرية.", - "symptoms_low": "الارتفاع الشديد قد يكون علامة ابيضاض دموي.", - }, - # سكر إضافي - { - "name_ar": "السكر العشوائي", "name_en": "Random Blood Sugar (RBS)", - "definition": "قياس السكر في أي وقت بغض النظر عن الأكل. الطبيعي: أقل من 140 mg/dL. مقلق: 140-199. سكري: 200 فأكثر مع أعراض.", - "high": "مرض السكري، الإجهاد، بعض الأدوية.", - "low": "نقص سكر الدم، جرعة أنسولين زائدة.", - "symptoms_low": "الارتفاع مع أعراض (عطش، كثرة تبول) يؤكد السكري.", - }, - { - "name_ar": "اختبار تحمل الجلوكوز", "name_en": "OGTT (Oral Glucose Tolerance Test)", - "definition": "يشرب المريض 75 غرام جلوكوز ويُقاس السكر بعد ساعتين. طبيعي: أقل من 140. ما قبل السكري: 140-199. سكري: 200+.", - "high": "سكري، مقاومة أنسولين، سكري الحمل.", - "low": "لا أهمية سريرية للانخفاض.", - "symptoms_low": "أفضل اختبار للكشف المبكر عن السكري.", - }, - { - "name_ar": "سي ببتيد", "name_en": "C-Peptide", - "definition": "ناتج ثانوي من إنتاج الأنسولين. يميز بين السكري النوع الأول والثاني. طبيعي: 0.5-2.0 ng/mL.", - "high": "مقاومة الأنسولين، ورم الأنسولين، السكري النوع الثاني.", - "low": "السكري النوع الأول، البنكرياس الضعيف.", - "symptoms_low": "C-Peptide منخفض = البنكرياس لا ينتج أنسولين = يحتاج أنسولين خارجي.", - }, - # كوليسترول إضافي - { - "name_ar": "الكوليسترول منخفض الكثافة جداً", "name_en": "VLDL Cholesterol", - "definition": "يحمل الدهون الثلاثية للأنسجة. يُحسب: VLDL = Triglycerides ÷ 5. طبيعي: 2-30 mg/dL.", - "high": "ارتفاع الدهون الثلاثية، السكري، السمنة.", - "low": "لا أهمية سريرية.", - "symptoms_low": "ارتفاعه يزيد خطر أمراض القلب.", - }, - # كبد إضافي - { - "name_ar": "جاما جلوتاميل ترانسفيريز", "name_en": "GGT (Gamma-Glutamyl Transferase)", - "definition": "إنزيم كبدي حساس للكحول والأدوية. طبيعي: رجال 8-61 U/L، نساء 5-36 U/L.", - "high": "الكحول، أمراض الكبد، بعض الأدوية، الكبد الدهني.", - "low": "لا أهمية سريرية.", - "symptoms_low": "أحساس مؤشر مبكر لتأثير الكحول على الكبد.", - }, - { - "name_ar": "البيليروبين المباشر", "name_en": "Direct (Conjugated) Bilirubin", - "definition": "البيليروبين المرتبط المعالج بالكبد. طبيعي: 0-0.3 mg/dL.", - "high": "انسداد القنوات الصفراوية، التهاب الكبد، حصوات المرارة.", - "low": "لا أهمية سريرية.", - "symptoms_low": "ارتفاعه مع اليرقان يشير لمشكلة في تصريف الصفراء.", - }, - # كلى إضافي - { - "name_ar": "اليوريا في الدم", "name_en": "Blood Urea Nitrogen (BUN) / Urea", - "definition": "ناتج تكسير البروتين، يُطرح بالكلى. BUN طبيعي: 7-20 mg/dL. يوريا: 2.5-7.1 mmol/L.", - "high": "ضعف الكلى، الجفاف، نزيف الجهاز الهضمي، أكل بروتين زائد.", - "low": "أمراض الكبد الشديدة، سوء التغذية.", - "symptoms_low": "نسبة BUN/Creatinine تحدد سبب ارتفاع البول النيتروجيني.", - }, - { - "name_ar": "الكلوريد", "name_en": "Chloride (Cl)", - "definition": "معدن يوازن السوائل والحموضة. طبيعي: 98-106 mEq/L.", - "high": "الجفاف، الحماض الكلوي، بعض الأدوية.", - "low": "القيء المتكرر، القصور الكلوي، الأدوية.", - "symptoms_low": "يُفسر دائماً مع الصوديوم والبوتاسيوم.", - }, - # هرمونات إضافية - { - "name_ar": "هرمون T3 الكلي والحر", "name_en": "T3 Total & Free T3", - "definition": "الهرمون الدرقي النشط. Free T3 طبيعي: 2.3-4.2 pg/mL.", - "high": "فرط نشاط الدرقية، التهاب الدرقية.", - "low": "قصور الدرقية، الأمراض الحادة.", - "symptoms_low": "Free T3 أدق من T3 الكلي في تقييم وظيفة الدرقية.", - }, - { - "name_ar": "هرمون T4 الكلي والحر", "name_en": "T4 Total & Free T4", - "definition": "الهرمون الدرقي الرئيسي المخزون. Free T4 طبيعي: 0.8-1.8 ng/dL.", - "high": "فرط نشاط الدرقية.", - "low": "قصور الدرقية.", - "symptoms_low": "يُقاس مع TSH لتقييم الغدة الدرقية بشكل كامل.", - }, - { - "name_ar": "الأجسام المضادة للغدة الدرقية", "name_en": "Anti-TPO (Thyroid Peroxidase Antibodies)", - "definition": "أجسام مضادة تهاجم الغدة الدرقية. طبيعي: أقل من 34 IU/mL.", - "high": "التهاب الغدة الدرقية هاشيموتو، داء غريفز.", - "low": "طبيعي.", - "symptoms_low": "ارتفاعه يؤكد المنشأ المناعي لمرض الدرقية.", - }, - { - "name_ar": "البروجسترون", "name_en": "Progesterone", - "definition": "هرمون الحمل وما بعد الإباضة. يتغير بحسب مرحلة الدورة والحمل.", - "high": "الحمل، بعض الأورام، فرط نشاط الغدة الكظرية.", - "low": "قصور الجسم الأصفر، خطر الإجهاض، عدم الإباضة.", - "symptoms_low": "الانخفاض في الحمل المبكر يستدعي متابعة طبية.", - }, - { - "name_ar": "الهرمون اللوتيني", "name_en": "LH (Luteinizing Hormone)", - "definition": "يحفز الإباضة عند المرأة وإنتاج التستوستيرون عند الرجل.", - "high": "انقطاع الطمث، قصور المبيض، تكيس المبايض.", - "low": "قصور الغدة النخامية.", - "symptoms_low": "نسبة LH/FSH مهمة في تشخيص تكيس المبايض.", - }, - { - "name_ar": "الهرمون المنبه للجريب", "name_en": "FSH (Follicle Stimulating Hormone)", - "definition": "يحفز نضج البويضات والحيوانات المنوية.", - "high": "انقطاع الطمث، قصور المبيض أو الخصية، انتهاء مخزون المبيض.", - "low": "قصور الغدة النخامية.", - "symptoms_low": "FSH مرتفع يعني احتياطي المبيض منخفض.", - }, - { - "name_ar": "ديهيدرو إيبي أندروستيرون", "name_en": "DHEA-S", - "definition": "هرمون من الغدة الكظرية، سلف للهرمونات الجنسية. طبيعي: يختلف بالعمر والجنس.", - "high": "أورام الكظرية، تكيس المبايض.", - "low": "قصور الكظرية، الشيخوخة.", - "symptoms_low": "الارتفاع عند النساء يسبب شعر زائد وحب شباب.", - }, - # معادن إضافية - { - "name_ar": "النحاس", "name_en": "Copper (Serum)", - "definition": "معدن ضروري لإنزيمات عدة. طبيعي: 70-140 µg/dL.", - "high": "الحمل، موانع الحمل، أمراض الكبد، متلازمة ويلسون.", - "low": "سوء تغذية، سوء امتصاص.", - "symptoms_low": "نقصه يسبب فقر الدم وضعف المناعة وهشاشة العظام.", - }, - { - "name_ar": "السيلينيوم", "name_en": "Selenium", - "definition": "معدن مضاد للأكسدة يدعم الغدة الدرقية والمناعة. طبيعي: 70-150 µg/L.", - "high": "تسمم نادر من المكملات.", - "low": "سوء التغذية، أمراض الأمعاء.", - "symptoms_low": "ضعف مناعة، ضعف عضلي، اضطراب درقي.", - }, - # قلب إضافي - { - "name_ar": "إنزيم القلب CK-MB", "name_en": "CK-MB (Creatine Kinase-MB)", - "definition": "إنزيم من عضلة القلب. يرتفع عند تلف القلب. طبيعي: أقل من 5% من CK الكلي.", - "high": "نوبة قلبية، التهاب عضلة القلب، صدمة شديدة.", - "low": "طبيعي.", - "symptoms_low": "يستخدم مع التروبونين لتأكيد النوبة القلبية.", - }, - { - "name_ar": "هرمون الببتيد الدماغي الناتريوريتيك", "name_en": "BNP / NT-proBNP", - "definition": "مؤشر فشل القلب. BNP طبيعي: أقل من 100 pg/mL.", - "high": "فشل القلب الاحتقاني، ارتفاع ضغط الدم الرئوي.", - "low": "طبيعي.", - "symptoms_low": "ارتفاعه مع ضيق تنفس يستدعي تقييم القلب فوراً.", - }, - { - "name_ar": "دي دايمر", "name_en": "D-Dimer", - "definition": "ناتج تكسير الجلطات. طبيعي: أقل من 500 ng/mL.", - "high": "جلطة وريدية، جلطة رئوية، التهاب شديد، حمل، سرطان.", - "low": "طبيعي - يستبعد الجلطة.", - "symptoms_low": "ارتفاعه مع أعراض الجلطة يستدعي تصوير طارئ.", - }, - # مناعة إضافية - { - "name_ar": "الأجسام المضادة للنواة", "name_en": "ANA (Anti-Nuclear Antibodies)", - "definition": "أجسام مضادة للخلايا، مؤشر الأمراض المناعية الذاتية.", - "high": "الذئبة الحمراء، التهاب المفاصل الروماتويدي، متلازمة شوغرن.", - "low": "سلبي = يستبعد أمراض مناعية ذاتية كثيرة.", - "symptoms_low": "نتيجة إيجابية تستدعي مزيداً من الفحوصات وليس تشخيصاً وحده.", - }, - { - "name_ar": "عامل الروماتويد", "name_en": "Rheumatoid Factor (RF)", - "definition": "جسم مضاد يوجد في الروماتويد. طبيعي: أقل من 20 IU/mL.", - "high": "التهاب المفاصل الروماتويدي، متلازمة شوغرن، أمراض كبدية.", - "low": "سلبي لا يستبعد الروماتويد بالضرورة (30% سلبي).", - "symptoms_low": "يُقرأ مع Anti-CCP لتشخيص الروماتويد.", - }, - { - "name_ar": "البروكالسيتونين", "name_en": "Procalcitonin (PCT)", - "definition": "مؤشر دقيق للعدوى البكتيرية. طبيعي: أقل من 0.1 ng/mL.", - "high": "إنتان (Sepsis)، عدوى بكتيرية شديدة.", - "low": "عدوى فيروسية أو لا عدوى.", - "symptoms_low": "يساعد على قرار إعطاء المضادات الحيوية من عدمه.", - }, - # تخثر إضافي - { - "name_ar": "زمن الثرومبوبلاستين الجزئي", "name_en": "aPTT (Activated Partial Thromboplastin Time)", - "definition": "يقيس مسار التخثر الداخلي. طبيعي: 25-35 ثانية.", - "high": "نقص عوامل التخثر، الهيبارين، الهيموفيليا.", - "low": "خطر تجلط.", - "symptoms_low": "يُستخدم لمراقبة علاج الهيبارين.", - }, - { - "name_ar": "الفيبرينوجين", "name_en": "Fibrinogen", - "definition": "بروتين أساسي في التخثر. طبيعي: 200-400 mg/dL.", - "high": "التهاب، حمل، أمراض قلبية وعائية.", - "low": "أمراض الكبد، استهلاك الفيبرينوجين في الجلطات.", - "symptoms_low": "الانخفاض الشديد يسبب نزيفاً خطيراً.", - }, - # بول إضافي - { - "name_ar": "بروتين البول", "name_en": "Urine Protein / Microalbuminuria", - "definition": "وجود بروتين في البول. طبيعي: أقل من 150 mg/يوم. بروتين دقيق: 30-300 mg/يوم.", - "high": "أمراض الكلى، السكري، ارتفاع الضغط.", - "low": "بول طبيعي.", - "symptoms_low": "أول علامة لتأثير السكري وضغط الدم على الكلى.", - }, - { - "name_ar": "الكيتونات في البول", "name_en": "Urine Ketones", - "definition": "ناتج حرق الدهون. طبيعي: سلبي.", - "high": "سكري غير متحكم به (طارئ)، صيام طويل، نظام كيتوني.", - "low": "سلبي طبيعي.", - "symptoms_low": "وجود كيتونات مع سكر مرتفع = الحماض الكيتوني (طارئ طبي).", - }, - { - "name_ar": "الكثافة النوعية للبول", "name_en": "Urine Specific Gravity", - "definition": "يقيس تركيز البول. طبيعي: 1.005-1.030.", - "high": "جفاف، إفراز هرمون ADH الزائد.", - "low": "شرب ماء زائد، مرض السكري الكاذب، فشل كلوي.", - "symptoms_low": "يعكس قدرة الكلى على تركيز البول.", - }, - # براز - { - "name_ar": "تحليل البراز الكامل", "name_en": "Stool Analysis (Ova & Parasites)", - "definition": "فحص البراز بحثاً عن طفيليات، بكتيريا، دم، خلايا.", - "high": "وجود طفيليات أو كريات بيضاء يدل على عدوى.", - "low": "براز طبيعي بدون طفيليات أو دم.", - "symptoms_low": "عند إسهال مزمن، ألم بطن، فقدان وزن غير مبرر.", - }, - { - "name_ar": "الدم الخفي في البراز", "name_en": "Fecal Occult Blood Test (FOBT)", - "definition": "يكشف الدم غير المرئي في البراز. طبيعي: سلبي.", - "high": "قرحة معدية، نزيف معوي، سرطان القولون، بواسير.", - "low": "سلبي = لا نزيف ظاهر.", - "symptoms_low": "اختبار أساسي لتحري سرطان القولون عند من فوق 45 سنة.", - }, - { - "name_ar": "جرثومة المعدة بالبراز", "name_en": "H. Pylori Stool Antigen", - "definition": "يكشف بكتيريا هيليكوباكتر بيلوري المسببة لقرحة المعدة.", - "high": "إيجابي = وجود البكتيريا = يحتاج علاجاً.", - "low": "سلبي = لا عدوى.", - "symptoms_low": "أفضل من فحص الدم لأنه يكشف العدوى الحالية.", - }, - # فيروسات - { - "name_ar": "التهاب الكبد B", "name_en": "Hepatitis B Surface Antigen (HBsAg)", - "definition": "يكشف عدوى التهاب الكبد B. طبيعي: سلبي.", - "high": "إيجابي = عدوى نشطة بالتهاب الكبد B.", - "low": "سلبي = لا عدوى. إيجابي HBsAb = محصّن (بلقاح أو شُفي).", - "symptoms_low": "التهاب الكبد B قد يكون صامتاً لسنوات ثم يتطور لتليف.", - }, - { - "name_ar": "التهاب الكبد C", "name_en": "Hepatitis C Antibody (HCV Ab)", - "definition": "يكشف الأجسام المضادة لفيروس C. طبيعي: سلبي.", - "high": "إيجابي يستدعي تأكيداً بـ PCR.", - "low": "سلبي = لا عدوى.", - "symptoms_low": "التهاب الكبد C صامت غالباً، يُعالج بالكامل الآن.", - }, - { - "name_ar": "تحليل فيروس نقص المناعة", "name_en": "HIV Test (4th Generation)", - "definition": "يكشف فيروس HIV والأجسام المضادة له. طبيعي: سلبي.", - "high": "إيجابي يستدعي تأكيداً بـ Western Blot.", - "low": "سلبي = لا عدوى.", - "symptoms_low": "الكشف المبكر والعلاج يجعل مرضى HIV يعيشون حياة طبيعية.", - }, - # حمل وخصوبة - { - "name_ar": "هرمون الحمل البيتا", "name_en": "Beta hCG (Human Chorionic Gonadotropin)", - "definition": "هرمون الحمل. يضاعف كل 48-72 ساعة في الحمل الطبيعي. فوق 25 mIU/mL = حمل.", - "high": "حمل، حمل خارج رحم، ورم الحمل.", - "low": "لا حمل، أو خطر إجهاض.", - "symptoms_low": "ارتفاع بطيء أو انخفاض يستدعي تقييم الحمل الخارج رحمي.", - }, - { - "name_ar": "مخزون المبيض AMH", "name_en": "AMH (Anti-Müllerian Hormone)", - "definition": "يعكس عدد البويضات المتبقية. طبيعي: 1-3.5 ng/mL (يختلف بالعمر).", - "high": "تكيس المبايض.", - "low": "انخفاض احتياطي المبيض، انقطاع الطمث المبكر.", - "symptoms_low": "AMH منخفض يعني صعوبة الحمل بالطرق الطبيعية.", - }, - { - "name_ar": "تحليل السائل المنوي", "name_en": "Semen Analysis (Spermogram)", - "definition": "يقيم جودة الحيوانات المنوية: العدد (>15 مليون/mL)، الحركة (>40%)، الشكل (>4%).", - "high": "لا أهمية سريرية للارتفاع.", - "low": "قلة الحيوانات المنوية أو ضعف حركتها = سبب للعقم الذكوري.", - "symptoms_low": "40% من حالات العقم سببها ذكوري جزئياً أو كلياً.", - }, - { - "name_ar": "الفوسفاتاز القلوية", "name_en": "ALP (Alkaline Phosphatase)", - "definition": "إنزيم من الكبد والعظام. طبيعي: 44-147 U/L.", - "high": "أمراض الكبد، أمراض العظام، فرط نشاط الغدة الدريقية.", - "low": "قصور الغدة الدريقية، فقر الدم الوخيم.", - "symptoms_low": "يُفسر مع ALT وAST لتحديد المصدر (كبد أم عظام).", - }, - { - "name_ar": "الصوديوم", "name_en": "Sodium (Na)", - "definition": "أهم معدن في السائل خارج الخلايا. طبيعي: 136-145 mEq/L.", - "high": "جفاف، إسهال، مدرات البول، فرط نشاط الغدة الكظرية.", - "low": "إكثار الماء، فشل قلبي، قصور الغدة الكظرية، بعض الأدوية.", - "symptoms_low": "الانخفاض: صداع، غثيان، تشنج، ارتباك. الارتفاع: عطش شديد، جفاف.", - }, - { - "name_ar": "البوتاسيوم", "name_en": "Potassium (K)", - "definition": "معدن حيوي لعمل القلب والعضلات. طبيعي: 3.5-5.0 mEq/L.", - "high": "فشل كلوي، مدرات بول حافظة للبوتاسيوم، تلف أنسجة.", - "low": "إسهال، قيء، مدرات البول، نقص تغذية.", - "symptoms_low": "الانخفاض: ضعف عضلي، تشنج، عدم انتظام القلب. الارتفاع: عدم انتظام قلب خطير.", - }, -] - -# ============================================================ -# القسم 2: التوصيات الصحية -# ============================================================ -HEALTH_RECOMMENDATIONS = [ - { - "topic": "فقر الدم والأنيميا", - "content": """نصائح لمرضى فقر الدم (الأنيميا): -الغذاء: تناول الأطعمة الغنية بالحديد: اللحوم الحمراء، الكبدة، السبانخ، العدس، الفاصوليا، الحبوب المدعمة. -تناول فيتامين C مع وجبات الحديد لتعزيز الامتصاص (برتقال، فلفل). -تجنب القهوة والشاي مع وجبات الحديد. -الرياضة: مشي خفيف 20-30 دقيقة يومياً، تجنب الإجهاد الشديد. -المتابعة: فحص الدم كل 3 أشهر حتى التحسن. -الأدوية: مكملات الحديد تُؤخذ على معدة فارغة أو مع عصير برتقال.""", - }, - { - "topic": "ارتفاع الكوليسترول", - "content": """نصائح لخفض الكوليسترول: -الغذاء: قلل الدهون المشبعة (لحم أحمر دهني، زبدة، جبن دسم). أكثر من الألياف: شوفان، تفاح، كمثرى، شعير. زيت الزيتون بديل ممتاز للدهون. -الرياضة: 150 دقيقة هوائية أسبوعياً (مشي سريع، سباحة، دراجة). -الوزن: كل كيلوغرام تخسره يخفض LDL بنسبة 1%. -التدخين: الإقلاع يرفع HDL بنسبة 10%. -المتابعة: فحص كل 6 أشهر مع العلاج.""", - }, - { - "topic": "مرض السكري وارتفاع السكر", - "content": """نصائح إدارة مرض السكري: -الغذاء: قلل النشويات المكررة (أرز أبيض، خبز أبيض، سكر). اختر الحبوب الكاملة. وزّع الوجبات 5-6 وجبات صغيرة. -الرياضة: 30 دقيقة يومياً تخفض السكر بشكل فوري وتحسن حساسية الأنسولين. -المراقبة: قياس السكر بانتظام. HbA1c كل 3 أشهر. -القدم السكري: فحص القدمين يومياً، حذاء مريح، لا مشي حافياً. -الأهداف: سكر صائم 80-130، بعد الأكل أقل من 180، HbA1c أقل من 7%.""", - }, - { - "topic": "ارتفاع ضغط الدم", - "content": """نصائح للتحكم في ضغط الدم: -الملح: قلل الصوديوم إلى أقل من 2300 mg يومياً (ملعقة صغيرة). -الغذاء DASH: خضروات، فواكه، حليب قليل الدسم، قلل اللحوم الحمراء. -الوزن: خسارة 5 كغ تخفض الضغط 5-10 mmHg. -الرياضة: 30 دقيقة يومياً تخفض الضغط 5-8 mmHg. -الكافيين والكحول: قللهما. -الإجهاد: تأمل، يوغا، تنفس عميق. -المتابعة: قياس الضغط يومياً في المنزل.""", - }, - { - "topic": "نقص فيتامين د", - "content": """نصائح لرفع مستوى فيتامين د: -الشمس: تعرض للشمس 15-30 دقيقة يومياً (بين 10 صباحاً و3 عصراً) على الذراعين والساقين. -الغذاء: سمك السلمون، السردين، صفار البيض، الحليب المدعم. -المكملات: عادة 1000-2000 IU يومياً (حسب توجيه الطبيب). -المتابعة: فحص بعد 3 أشهر من العلاج. -ملاحظة: فيتامين د يُمتص مع الدهون، تناوله مع وجبة دسمة.""", - }, - { - "topic": "التعب والإجهاد المزمن", - "content": """نصائح لمكافحة التعب المزمن: -فحوصات مقترحة: CBC، فيريتين، B12، فيتامين D، TSH، سكر الدم. -النوم: 7-9 ساعات، نظام نوم ثابت، تجنب الشاشات قبل النوم. -الغذاء: وجبات منتظمة، قلل السكر المكرر، أكثر من البروتين. -الرياضة: مشي 20 دقيقة يومياً يزيد الطاقة بشكل مثبت علمياً. -الإجهاد: تقنيات الاسترخاء، تحديد الأولويات، طلب المساعدة. -الترطيب: قلة الماء وحدها تسبب التعب، اشرب 8 أكواب يومياً.""", - }, - { - "topic": "صحة الغدة الدرقية", - "content": """نصائح للعناية بالغدة الدرقية: -اليود: ضروري لعمل الغدة (ملح معالج باليود، أسماك بحرية). -السيلينيوم: يدعم الغدة (مكسرات البرازيل، التونة). -تجنب: كميات كبيرة من الكرنب والقرنبيط النيء عند مرضى الغدة. -الدواء: ليفوثيروكسين يُؤخذ صائماً قبل الأكل بـ 30 دقيقة. -المتابعة: TSH كل 6-12 شهراً. -الحمل: TSH يجب أن يكون أقل من 2.5 خلال الحمل.""", - }, - { - "topic": "صحة الكلى", - "content": """نصائح للحفاظ على وظائف الكلى: -الماء: اشرب 2-3 لترات يومياً لتنظيف الكلى. -الملح: قلله لتقليل الضغط على الكلى. -البروتين: لا تفرط في البروتين عند ضعف وظائف الكلى. -الأدوية: تجنب المسكنات (NSAIDS) بكثرة. -السكر والضغط: السيطرة عليهما أهم ما يحمي الكلى. -المتابعة: كرياتينين ومعدل الترشيح GFR سنوياً للمعرضين للخطر.""", - }, - { - "topic": "صحة الكبد", - "content": """نصائح لصحة الكبد: -الوزن: السمنة أكبر سبب للكبد الدهني، خسارة 7-10% من الوزن تحسن الكبد. -الغذاء: قهوة بدون سكر تحمي الكبد (دراسات متعددة). قلل السكر والدهون. -الكحول: الابتعاد عنه تماماً. -الأدوية: لا تأخذ أدوية دون وصفة لفترة طويلة. -التطعيم: لقاح التهاب الكبد A وB. -المتابعة: ALT و AST كل سنة.""", - }, - { - "topic": "تحسين صحة القلب", - "content": """نصائح للقلب الصحي: -الغذاء: نظام البحر المتوسط: زيت زيتون، سمك، خضروات، مكسرات. -الرياضة: 150 دقيقة هوائية أسبوعياً + تمارين مقاومة مرتين. -التوتر: يرفع ضغط الدم والكوليسترول. إدارته مهمة. -النوم: 7-9 ساعات. قلة النوم ترفع خطر القلب. -التدخين: الإقلاع يخفض الخطر القلبي 50% بعد سنة. -الفحوصات: ضغط الدم، كوليسترول، سكر، مؤشر الكتلة الجسمية.""", - }, - { - "topic": "الصداع والصداع النصفي", - "content": """نصائح لتخفيف الصداع: -المحفزات: تعرف على محفزاتك (الضوء، الضجيج، أطعمة معينة كالشوكولاتة والجبن القديم). -الترطيب: الجفاف سبب رئيسي للصداع. اشرب 8 أكواب ماء يومياً. -النوم: نظام نوم ثابت. كثرة النوم وقلته كلاهما يسببان صداعاً. -الرياضة: المشي المنتظم يقلل تكرار الصداع النصفي. -الإجهاد: التوتر محفز قوي. تمارين التنفس تساعد. -الكافيين: الجرعة الصغيرة تخفف الصداع لكن الإفراط يسببه. -متى تذهب للطبيب: صداع مفاجئ شديد، مع حمى، أو تغير في الرؤية.""", - }, - { - "topic": "آلام المفاصل والتهاب المفاصل", - "content": """نصائح لآلام المفاصل: -الوزن: كل كيلوغرام زائد يضع 4 كغ ضغطاً على الركبة. -الرياضة: السباحة والدراجة الأقل ضغطاً على المفاصل، تقوّي العضلات. -الحرارة والبرودة: الحرارة للألم المزمن، البرودة للالتهاب الحاد. -أوميغا 3: السمك والكتان يقللان الالتهاب. -تجنب: الوقوف الطويل، صعود الدرج المتكرر عند الألم الحاد. -المكملات: الجلوكوزامين قد يساعد بعض الحالات. -فحوصات: حمض اليوريك (للنقرس)، CRP، أشعة للمفصل.""", - }, - { - "topic": "آلام الظهر والعمود الفقري", - "content": """نصائح لآلام الظهر: -الجلوس: ظهر مستقيم، كرسي داعم للظهر، لا تجلس أكثر من ساعة متواصلة. -التمدد: تمارين تقوية عضلات البطن والظهر تحمي العمود الفقري. -النوم: على جانبك مع وسادة بين الركبتين، أو على ظهرك مع وسادة تحت الركبتين. -الرفع: انحنِ بركبتيك لا بظهرك عند رفع الأثقال. -الوزن: السمنة تزيد الضغط على الفقرات القطنية. -متى تذهب للطبيب: ألم ينتشر للساق، ضعف أو تنميل، صعوبة في التبول.""", - }, - { - "topic": "الغدة الدرقية الخاملة (قصور)", - "content": """نصائح لقصور الغدة الدرقية: -الدواء: ليفوثيروكسين يومياً صائماً، 30-60 دقيقة قبل الإفطار. -لا تأخذه مع: الكالسيوم، الحديد، مضادات الحموضة (تقلل الامتصاص). -الغذاء: اليود ضروري (ملح معالج، أسماك بحرية). السيلينيوم يدعم الغدة. -تجنب الإفراط في: الكرنب، البروكلي، فول الصويا النيء (تثبط الغدة). -المتابعة: TSH كل 6 أشهر، مع كل تغيير في الجرعة بعد 6-8 أسابيع. -الأعراض التي تستدعي مراجعة الجرعة: زيادة وزن، تعب مفرط، برودة، إمساك.""", - }, - { - "topic": "نقص حمض الفوليك", - "content": """نصائح لرفع مستوى حمض الفوليك: -الغذاء: خضروات ورقية داكنة (سبانخ، بروكلي)، بقوليات، حبوب مدعمة، كبدة. -الطهي: الطهي يدمر 50-90% من الفولات. تناول بعض الخضروات نيئة. -المكملات: 400-800 ميكروجرام يومياً للنساء في سن الإنجاب. -الحمل: 400-800 ميكروجرام قبل الحمل وفي الأشهر الأولى يمنع تشوهات الجنين. -الكحول: يتدخل في امتصاص الفولات تجنبه. -المتابعة: فحص مستوى الفولات والـ B12 معاً لأن نقصهما متشابه.""", - }, - { - "topic": "مقاومة الأنسولين", - "content": """نصائح لتحسين حساسية الأنسولين: -الغذاء: قلل الكربوهيدرات المكررة، اختر الحبوب الكاملة والبقوليات. -الرياضة: أفضل علاج لمقاومة الأنسولين. 30 دقيقة يومياً من الرياضة الهوائية. -الوزن: خسارة 5-10% من الوزن تحسن حساسية الأنسولين بشكل كبير. -النوم: قلة النوم ترفع مقاومة الأنسولين. اهدف لـ 7-8 ساعات. -الإجهاد: الكورتيزول يرفع مقاومة الأنسولين. إدارة التوتر مهمة. -المتابعة: سكر صائم، أنسولين صائم، HbA1c كل 6 أشهر.""", - }, - { - "topic": "الكبد الدهني", - "content": """نصائح لعلاج الكبد الدهني: -الوزن: الهدف الأهم. خسارة 7-10% من الوزن تحسن الكبد الدهني بشكل ملحوظ. -السكر: تقليل السكر والفركتوز (مشروبات غازية، عصائر مصنعة) أهم خطوة. -القهوة: 2-3 أكواب قهوة يومياً تحمي الكبد (بدون سكر زائد). -الرياضة: 30 دقيقة يومياً حتى بدون خسارة وزن تحسن الكبد. -الكحول: ممنوع تماماً في الكبد الدهني. -المتابعة: ALT وAST كل 6 أشهر، أشعة تحديد بالموجات الصوتية.""", - }, - { - "topic": "ضعف المناعة والوقاية من العدوى", - "content": """نصائح لتقوية جهاز المناعة: -التغذية: فيتامين C (حمضيات)، زنك (لحم، بذور يقطين)، فيتامين D (شمس ومكملات). -النوم: 7-9 ساعات. النوم الكافي يضاعف فاعلية اللقاحات ويقوي المناعة. -الرياضة: المعتدلة تقوي المناعة، المفرطة تضعفها. -الإجهاد: التوتر المزمن يقمع المناعة. مارس تقنيات الاسترخاء. -الماء: الجفاف يضعف إنتاج الأجسام المضادة. -التدخين: يدمر خلايا المناعة في الجهاز التنفسي. -الميكروبيوم: تناول البروبيوتيك والألياف لدعم بكتيريا الأمعاء المفيدة.""", - }, - { - "topic": "الوقاية من هشاشة العظام", - "content": """نصائح لصحة العظام: -الكالسيوم: 1000-1200 mg يومياً من الغذاء (حليب، جبن، لبن، سبانخ، سردين). -فيتامين D: ضروري لامتصاص الكالسيوم. 800-2000 IU يومياً. -الرياضة: رياضة تحمل الوزن (مشي، جري، رفع أثقال) تبني كثافة العظام. -تجنب: التدخين والكحول الزائد (يضران بالعظام). -الكافيين: الإفراط يقلل امتصاص الكالسيوم. -الفحص: DEXA scan (قياس كثافة العظام) للنساء بعد انقطاع الطمث.""", - }, - { - "topic": "صحة الجهاز الهضمي", - "content": """نصائح لصحة الجهاز الهضمي: -الألياف: 25-35 غرام يومياً من الفواكه والخضروات والحبوب الكاملة. -الماء: ضروري لمنع الإمساك وتحريك الطعام. -البروبيوتيك: لبن، كفير، مخلل طبيعي لدعم بكتيريا الأمعاء. -الأكل: ببطء ومضغ جيد يقلل الغازات والانتفاخ. -الإجهاد: يؤثر مباشرة على الأمعاء (القولون العصبي). -تجنب: الأطعمة المصنعة، الدهون المشبعة، الإكثار من المضادات الحيوية. -متى تراجع الطبيب: دم في البراز، فقدان وزن غير مبرر، ألم مستمر.""", - }, - { - "topic": "النقرس وارتفاع حمض اليوريك", - "content": """نصائح للنقرس: -الماء: 2-3 لترات يومياً تساعد الكلى على إفراز حمض اليوريك. -تجنب: اللحوم الحمراء، المأكولات البحرية، الكبدة والكلى (غنية بالبيورينات). -قلل: الكحول خاصة البيرة، المشروبات المحلاة بالفركتوز. -أضف: الكرز (طازج أو عصير) يقلل نوبات النقرس. -الوزن: السمنة ترفع حمض اليوريك، خسارة وزن تدريجية مهمة. -الأدوية: الألوبيورينول للوقاية، الكولشيسين للنوبة الحادة. -الفحص: حمض اليوريك في الدم مرة كل 6 أشهر.""", - }, - { - "topic": "القلق والتوتر النفسي", - "content": """نصائح لإدارة القلق: -التنفس: تقنية 4-7-8: شهيق 4 ثوان، احتباس 7، زفير 8. تهدئ الجهاز العصبي فوراً. -الرياضة: 30 دقيقة مشي يومياً تعادل مضادات القلق في بعض الدراسات. -النوم: قلة النوم تزيد القلق. نظام نوم ثابت أساسي. -الكافيين: يزيد القلق عند الحساسين. قلله أو تجنبه بعد الظهر. -التأمل: 10 دقائق يومياً تغير بنية الدماغ وتقلل القلق مثبتاً علمياً. -دعم اجتماعي: التحدث مع شخص موثوق يخفف عبء القلق. -متى تطلب مساعدة: إذا تداخل القلق مع حياتك اليومية.""", - }, - { - "topic": "الاكتئاب وتحسين المزاج", - "content": """نصائح لتحسين الصحة النفسية: -الرياضة: أثبتت الدراسات أن 45 دقيقة هوائية 3 مرات/أسبوع فعّالة كالدواء في الاكتئاب الخفيف. -الشمس: 20 دقيقة يومياً ترفع السيروتونين وتنظم الساعة البيولوجية. -النوم: اضطراب النوم وثيق الصلة بالاكتئاب. علاج أحدهما يحسن الآخر. -التغذية: أوميغا 3، فيتامين D، المغنيسيوم، B12 كلها مرتبطة بالمزاج. -التواصل: العزلة تسوء الاكتئاب. حافظ على علاقاتك الاجتماعية. -الفحوصات: استبعد نقص الغدة الدرقية، فيتامين D، B12 كأسباب عضوية. -متى تطلب مساعدة: أفكار إيذاء النفس تستدعي مساعدة فورية.""", - }, - { - "topic": "متلازمة القولون العصبي IBS", - "content": """نصائح لمرضى القولون العصبي: -FODMAP: نظام غذائي يقلل السكريات القابلة للتخمر. يساعد 75% من المرضى. -الإجهاد: القولون العصبي يرتبط مباشرة بالتوتر. إدارة الإجهاد علاج حقيقي. -النعناع: زيت النعناع كبسولات مغلفة يقلل تشنجات القولون. -الألياف: الألياف القابلة للذوبان (شوفان، بذور الكتان) أفضل من غير القابلة. -الأكل: ببطء، وجبات صغيرة منتظمة، تجنب الوجبات الكبيرة. -سجل الطعام: دوّن ما تأكله وأعراضك لتحديد المحفزات الشخصية.""", - }, - { - "topic": "الحموضة وارتجاع المريء GERD", - "content": """نصائح لارتجاع المريء: -الوزن: تقليل الوزن يخفف الضغط على المعدة ويقلل الارتجاع. -الوجبات: صغيرة ومتكررة، لا تنم قبل 3 ساعات من الأكل. -رفع الرأس: ارفع رأس السرير 15-20 سم عند النوم على الجانب الأيسر. -تجنب: القهوة، الشوكولاتة، الحمضيات، الطماطم، الأطعمة الدهنية والحارة. -الملابس: تجنب الملابس الضيقة التي تضغط على البطن. -التدخين والكحول: يرخيان العضلة الحاجزة ويزيدان الارتجاع.""", - }, - { - "topic": "حصوات الكلى", - "content": """نصائح للوقاية من حصوات الكلى: -الماء: أهم علاج وقاية. 2.5-3 لترات يومياً تخفف تركيز المعادن. -الكالسيوم: لا تقلل الكالسيوم الغذائي (يرتبط بالأكسالات في الأمعاء ويمنعها). -الملح والبروتين الحيواني: قللهما فإنهما يرفعان الكالسيوم في البول. -الأكسالات: قلل السبانخ، المكسرات، الشوكولاتة إذا كانت حصوات أكسالات. -الليمون: عصير ليمون يومياً يرفع السترات ويمنع تكوّن الحصوات. -متابعة: تحليل بول 24 ساعة لتحديد نوع الحصوات والوقاية المناسبة.""", - }, - { - "topic": "متلازمة تكيس المبايض PCOS", - "content": """نصائح لمتلازمة تكيس المبايض: -الوزن: خسارة 5-10% من الوزن تنظم الدورة وتحسن الخصوبة بشكل ملحوظ. -الغذاء: نظام منخفض المؤشر الجلايسيمي يحسن مقاومة الأنسولين. -الرياضة: تحسن حساسية الأنسولين وتنظم الهرمونات. -الميتفورمين: يستخدمه الأطباء لتحسين حساسية الأنسولين في PCOS. -الإنوزيتول: مكمل طبيعي يساعد في تنظيم الهرمونات. -الفحوصات: سكر صائم وأنسولين، هرمونات (LH/FSH/تستوستيرون)، TSH، أشعة مبايض.""", - }, - { - "topic": "الوقاية من أمراض القلب", - "content": """الوقاية من أمراض القلب والشرايين: -الفحوصات الدورية: ضغط الدم، كوليسترول، سكر، مؤشر الكتلة الجسمية كل سنة. -الغذاء القلبي: قلل الدهون المشبعة والصوديوم. أكثر من الخضروات والفواكه والأسماك. -الرياضة: 150 دقيقة أسبوعياً من النشاط المعتدل. -الإقلاع عن التدخين: أهم قرار صحي لصحة القلب. -الوزن: مؤشر الكتلة الجسمية 18.5-24.9 مثالي. -إدارة الإجهاد: التوتر المزمن يرفع ضغط الدم ويزيد الالتهاب. -الأسبرين: لا تأخذه للوقاية بدون استشارة طبيب.""", - }, - { - "topic": "الربو وصحة الجهاز التنفسي", - "content": """نصائح لمرضى الربو: -المحفزات: تعرف عليها وتجنبها (غبار، دخان، عطور، برد، مجهود). -البيئة: مكيف هواء نظيف، تغيير مخدات وأغطية باستمرار، لا سجاد في غرفة النوم. -البخاخ الموسع: دائماً معك للطوارئ. استخدم بالتسلسل الصحيح. -الكورتيزون الاستنشاقي: للوقاية وليس للطوارئ فقط. لا تتوقف عنه. -الرياضة: ممكنة مع الربو. المسبح الأنسب (رطوبة عالية). -متى تذهب للطوارئ: صعوبة تنفس شديدة، شفاه زرقاء، عدم استجابة للبخاخ.""", - }, - { - "topic": "السمنة وإدارة الوزن", - "content": """نصائح صحية لإنقاص الوزن: -الهدف الواقعي: 0.5-1 كغ أسبوعياً. السرعة تسبب فقدان عضلات ويوثيرو. -العجز الحراري: قلل 500 سعرة يومياً (غذاء أقل + رياضة أكثر) = 0.5 كغ أسبوعياً. -البروتين: يزيد الشبع ويحافظ على العضلات. أضفه في كل وجبة. -الماء: اشرب كوب قبل كل وجبة. أحياناً يخلط الجسم العطش بالجوع. -النوم: قلة النوم ترفع هرمون الجوع (غريلين) وتقلل الشبع (ليبتين). -الصبر: الوزن المُفقود ببطء يعود ببطء. 6 أشهر للعادات الثابتة.""", - }, -] - -# ============================================================ -# القسم 3: قائمة مواضيع MedlinePlus الموسعة -# ============================================================ -MEDLINEPLUS_TOPICS = [ - # تحاليل الدم - ("blood glucose diabetes test", "glucose test", "lab_test"), - ("hemoglobin anemia blood test", "hemoglobin", "lab_test"), - ("complete blood count CBC test", "CBC", "lab_test"), - ("cholesterol test lipid panel", "cholesterol", "lab_test"), - ("thyroid function test TSH T4", "thyroid test", "lab_test"), - ("liver function test ALT AST", "liver function", "lab_test"), - ("kidney function creatinine BUN test", "kidney function", "lab_test"), - ("HbA1c glycated hemoglobin test", "HbA1c", "lab_test"), - ("iron studies ferritin test", "iron ferritin", "lab_test"), - ("vitamin D blood test deficiency", "vitamin D test", "lab_test"), - ("vitamin B12 blood test", "vitamin B12 test", "lab_test"), - ("uric acid gout test", "uric acid", "lab_test"), - ("C-reactive protein CRP test", "CRP", "lab_test"), - ("PSA prostate test", "PSA", "lab_test"), - ("blood pressure measurement", "blood pressure", "lab_test"), - ("electrolytes sodium potassium test", "electrolytes", "lab_test"), - ("calcium blood test", "calcium", "lab_test"), - ("platelet count test", "platelet", "lab_test"), - # الأمراض - ("diabetes mellitus management treatment", "diabetes", "disease"), - ("hypertension high blood pressure", "hypertension", "disease"), - ("anemia iron deficiency treatment", "anemia", "disease"), - ("hypothyroidism underactive thyroid", "hypothyroidism", "disease"), - ("hyperthyroidism overactive thyroid Graves", "hyperthyroidism", "disease"), - ("coronary artery disease heart attack", "heart disease", "disease"), - ("chronic kidney disease renal failure", "kidney disease", "disease"), - ("fatty liver disease NAFLD", "fatty liver", "disease"), - ("gout uric acid joint pain", "gout", "disease"), - ("osteoporosis bone density", "osteoporosis", "disease"), - ("high cholesterol hyperlipidemia", "high cholesterol", "disease"), - ("asthma breathing treatment", "asthma", "disease"), - ("urinary tract infection UTI", "UTI", "disease"), - ("irritable bowel syndrome IBS", "IBS", "disease"), - ("acid reflux GERD heartburn", "GERD", "disease"), - ("polycystic ovary syndrome PCOS", "PCOS", "disease"), - ("obesity overweight treatment", "obesity", "disease"), - ("metabolic syndrome insulin resistance", "metabolic syndrome", "disease"), - ("celiac disease gluten", "celiac", "disease"), - ("rheumatoid arthritis treatment", "rheumatoid arthritis", "disease"), - ("depression treatment mental health", "depression", "disease"), - ("anxiety disorder treatment", "anxiety disorder", "disease"), - ("sleep apnea insomnia", "sleep disorders", "disease"), - ("migraine headache treatment", "migraine", "disease"), - ("stroke cerebrovascular prevention", "stroke", "disease"), - ("pneumonia respiratory infection", "pneumonia", "disease"), - ("osteoarthritis joint pain", "osteoarthritis", "disease"), - ("fibromyalgia chronic pain", "fibromyalgia", "disease"), - # الأعراض - ("fatigue tiredness causes treatment", "fatigue", "symptom"), - ("headache causes treatment", "headache", "symptom"), - ("fever temperature causes", "fever", "symptom"), - ("dizziness vertigo causes", "dizziness", "symptom"), - ("shortness of breath dyspnea", "shortness of breath", "symptom"), - ("chest pain causes", "chest pain", "symptom"), - ("abdominal pain stomach ache", "abdominal pain", "symptom"), - ("nausea vomiting causes", "nausea", "symptom"), - ("back pain lower back", "back pain", "symptom"), - ("weight loss unintentional", "weight loss", "symptom"), - ("hair loss causes treatment", "hair loss", "symptom"), - ("skin rash allergy causes", "skin rash", "symptom"), - ("joint pain causes treatment", "joint pain", "symptom"), - ("muscle weakness causes", "muscle weakness", "symptom"), - ("heart palpitations causes", "palpitations", "symptom"), - ("insomnia sleep problems", "insomnia", "symptom"), - ("frequent urination causes", "frequent urination", "symptom"), - ("excessive thirst polydipsia", "excessive thirst", "symptom"), - ("blurred vision eye problems", "blurred vision", "symptom"), - ("swollen feet ankles edema", "edema", "symptom"), - ("night sweats causes", "night sweats", "symptom"), - ("chronic cough causes", "chronic cough", "symptom"), - ("numbness tingling hands feet", "numbness", "symptom"), - ("memory problems cognitive", "memory problems", "symptom"), - ("cold intolerance thyroid", "cold intolerance", "symptom"), - # التغذية والوقاية - ("healthy diet nutrition tips", "healthy diet", "prevention"), - ("exercise physical activity benefits", "exercise benefits", "prevention"), - ("sleep hygiene tips better sleep", "sleep health", "prevention"), - ("stress management mental health", "stress management", "prevention"), - ("water hydration health benefits", "hydration", "prevention"), - ("mediterranean diet heart health", "mediterranean diet", "prevention"), - ("vitamin supplements when needed", "vitamins supplements", "prevention"), - ("weight loss healthy strategies", "weight management", "prevention"), - ("smoking cessation health benefits", "quit smoking", "prevention"), - ("alcohol health effects risks", "alcohol health", "prevention"), - ("preventive health screenings checkup", "health screenings", "prevention"), - ("blood sugar control tips", "blood sugar control", "prevention"), -] - -# ============================================================ -# القسم 4: الأسئلة الشائعة (FAQ) بالعربية -# ============================================================ -FAQS = [ - { - "q": "ما سبب الشعور بالتعب المستمر والإجهاد؟", - "a": """التعب المستمر له أسباب طبية كثيرة يجب فحصها:\n- نقص الحديد والفيريتين: الأكثر شيوعاً عند النساء\n- نقص فيتامين D أو B12\n- قصور الغدة الدرقية (TSH مرتفع)\n- فقر الدم (هيموجلوبين منخفض)\n- السكري أو مقاومة الأنسولين\n- اكتئاب أو قلق مزمن\n- قلة النوم أو توقف التنفس أثناء النوم\n- الجفاف وقلة شرب الماء\nالتحاليل المقترحة: CBC، فيريتين، فيتامين D، فيتامين B12، TSH، سكر صائم، وظائف الكلى والكبد.""", - }, - { - "q": "لماذا انخفض الهيموجلوبين عندي؟ ما أسباب فقر الدم؟", - "a": """أسباب انخفاض الهيموجلوبين وفقر الدم:\n- نقص الحديد: السبب الأشيع (نزيف، حيض غزير، سوء تغذية)\n- نقص فيتامين B12 أو حمض الفوليك\n- أمراض الكلى المزمنة\n- الثلاسيميا (وراثية)\n- التهابات مزمنة\nأعراضه: تعب، شحوب، ضيق تنفس، خفقان، دوخة.\nالعلاج: مكملات حديد مع فيتامين C، B12، أو فولات حسب السبب.""", - }, - { - "q": "كيف أرفع مستوى الحديد والفيريتين في الدم؟", - "a": """لرفع الحديد والفيريتين:\nالغذاء: اللحوم الحمراء، الكبدة، السبانخ، العدس، الفاصوليا، الحبوب المدعمة.\nتناول فيتامين C مع وجبة الحديد (برتقال، فلفل أحمر) لتحسين الامتصاص 3 أضعاف.\nتجنب: القهوة والشاي والكالسيوم مع وجبة الحديد.\nالمكملات: كبريتات الحديد أو غلوكونات الحديد على معدة فارغة.\nالمتابعة: فيريتين كل 3 أشهر حتى يصل للمستوى المثالي (فوق 50 ng/mL).""", - }, - { - "q": "ما معنى ارتفاع الكوليسترول وكيف أخفضه؟", - "a": """الكوليسترول المرتفع يزيد خطر أمراض القلب والجلطات.\nأسبابه: السمنة، قلة الرياضة، الوراثة، السكري، قصور الدرقية.\nللتخفيض:\n- الغذاء: قلل الدهون المشبعة، أكثر من الألياف (شوفان، تفاح)، زيت زيتون.\n- الرياضة: 150 دقيقة هوائية أسبوعياً.\n- الوزن: كل 1 كغ تخسره يخفض LDL بـ 1%.\n- الإقلاع عن التدخين يرفع HDL بـ 10%.\n- الأدوية: ستاتين إذا لزم بوصفة طبيب.""", - }, - { - "q": "ما هو مستوى السكر الطبيعي؟ متى يكون السكري؟", - "a": """مستويات سكر الدم:\n- طبيعي صائم: 70-100 mg/dL\n- ما قبل السكري: 100-125 mg/dL\n- سكري: 126 فأكثر (في فحصين منفصلين)\n- بعد الأكل الطبيعي: أقل من 140\n- HbA1c طبيعي: أقل من 5.7%. ما قبل السكري: 5.7-6.4%. سكري: 6.5% فأكثر.\nأعراض السكري: كثرة التبول، عطش شديد، تعب، تعب بعد الأكل، بطء التئام الجروح.""", - }, - { - "q": "ما معنى ارتفاع TSH؟ ما أعراض قصور الغدة الدرقية؟", - "a": """TSH مرتفع يعني أن الغدة الدرقية خاملة (قصور).\nالقيم الطبيعية: 0.4-4.0 mIU/L. فوق 4 = قصور.\nأعراض قصور الدرقية:\n- تعب وإرهاق مستمر\n- زيادة وزن بدون سبب\n- برودة دائمة حتى في الجو الدافئ\n- إمساك\n- بطء ضربات القلب\n- تساقط الشعر وجفاف الجلد\n- اكتئاب وبطء التفكير\nالعلاج: ليفوثيروكسين يومياً على معدة فارغة.""", - }, - { - "q": "كيف أرفع فيتامين د؟ ما أعراض نقصه؟", - "a": """أعراض نقص فيتامين D:\n- آلام العظام والعضلات\n- تعب مزمن\n- ضعف المناعة وتكرار الأمراض\n- اكتئاب ومزاج منخفض\n- تساقط الشعر\nلرفع فيتامين D:\n- الشمس: 15-30 دقيقة يومياً على الذراعين والساقين بين 10 صباحاً و3 عصراً\n- الغذاء: سمك السلمون، السردين، صفار البيض، حليب مدعم\n- المكملات: 2000-5000 IU يومياً (حسب توجيه الطبيب)\n- فيتامين D يُمتص مع الدهون، تناوله مع وجبة دسمة\nمتابعة: فحص بعد 3 أشهر.""", - }, - { - "q": "ما أعراض نقص فيتامين ب12؟ ومن الأكثر عرضة له؟", - "a": """أعراض نقص فيتامين B12:\n- تنميل ووخز في اليدين والقدمين\n- تعب وضعف\n- فقر الدم الضخم الكريات\n- ضعف الذاكرة والتركيز\n- التهاب اللسان\n- مشية غير ثابتة\nالأكثر عرضة للنقص:\n- النباتيون والخضريون (B12 موجود فقط في المنتجات الحيوانية)\n- كبار السن (ضعف الامتصاص)\n- من يأخذون الميتفورمين لعلاج السكري\n- أمراض المعدة والأمعاء\nالعلاج: حقن B12 أو مكملات عالية الجرعة (1000 mcg يومياً).""", - }, - { - "q": "ما معنى ارتفاع CRP؟ ما هو بروتين سي التفاعلي؟", - "a": """CRP (بروتين سي التفاعلي) مؤشر للالتهاب في الجسم.\nطبيعي: أقل من 10 mg/L.\nCRP عالي يعني وجود التهاب أو عدوى في مكان ما.\nأسباب الارتفاع:\n- عدوى بكتيرية أو فيروسية\n- التهاب المفاصل الروماتويدي\n- أمراض الأمعاء الالتهابية\n- نوبة قلبية حديثة\n- السمنة والسكري\nhigh-sensitivity CRP (hs-CRP): فوق 3 mg/L يشير لخطر قلبي مرتفع.\nالمتابعة: يُفسر مع ESR وأعراض المريض.""", - }, - { - "q": "ما معنى ارتفاع حمض اليوريك؟ ما هو النقرس؟", - "a": """حمض اليوريك يتكون من تكسير البيورينات في الطعام.\nطبيعي: رجال 3.5-7.2، نساء 2.6-6.0 mg/dL.\nأسباب الارتفاع: اللحوم الحمراء، المأكولات البحرية، الكحول، السمنة، الفشل الكلوي.\nالنقرس: ترسب بلورات حمض اليوريك في المفاصل خاصة إصبع القدم الكبير.\nأعراض النوبة: ألم شديد مفاجئ، تورم، احمرار في المفصل.\nالعلاج:\n- تجنب اللحوم الحمراء والبحرية والكحول\n- شرب 2-3 لترات ماء يومياً\n- كولشيسين للنوبة الحادة، ألوبيورينول للوقاية""", - }, - { - "q": "ما معنى ارتفاع الكرياتينين؟ كيف أعرف أن كليتي بخير؟", - "a": """الكرياتينين مؤشر لوظائف الكلى.\nطبيعي: رجال 0.74-1.35، نساء 0.59-1.04 mg/dL.\nارتفاعه يعني الكلى لا تصفي الدم بكفاءة.\nأسباب الارتفاع: أمراض الكلى المزمنة، الجفاف، السكري، ارتفاع الضغط.\nمؤشرات الكلى الكاملة: كرياتينين + eGFR + يوريا + تحليل بول.\neGFR طبيعي: فوق 90 mL/min. أقل من 60 = مرض كلوي مزمن.\nللحفاظ على الكلى: ماء كافٍ، تحكم في السكر والضغط، تجنب المسكنات.""", - }, - { - "q": "ما أعراض مرض السكري؟ كيف أعرف أن عندي سكري؟", - "a": """أعراض السكري الكلاسيكية:\n- كثرة التبول (خاصة ليلاً)\n- عطش شديد ومستمر\n- تعب وإرهاق\n- جوع مستمر رغم الأكل\n- تعتم أو ضبابية في الرؤية\n- بطء التئام الجروح\n- تنميل أو وخز في القدمين\n- فقدان وزن غير مبرر (النوع الأول)\nالتشخيص بالتحاليل:\n- سكر صائم 126 mg/dL فأكثر (مرتين)\n- HbA1c 6.5% فأكثر\n- سكر عشوائي 200 فأكثر مع أعراض""", - }, - { - "q": "ما أسباب تساقط الشعر؟ كيف أوقف تساقط الشعر؟", - "a": """أسباب تساقط الشعر الشائعة:\n- نقص الحديد والفيريتين: السبب الأول عند النساء\n- نقص فيتامين D أو B12 أو الزنك\n- قصور أو فرط الغدة الدرقية\n- تكيس المبايض PCOS عند النساء (هرمونات ذكورية مرتفعة)\n- التوتر الشديد والصدمات\n- بعض الأدوية\n- الثعلبة (alopecia areata): مناعية\nالتحاليل المقترحة: CBC، فيريتين، فيتامين D، B12، TSH، زنك، هرمونات.\nالعلاج يعتمد على السبب.""", - }, - { - "q": "ما سبب ارتفاع ضغط الدم؟ كيف أخفض الضغط؟", - "a": """أسباب ارتفاع ضغط الدم:\n- الوراثة والعمر\n- السمنة والوزن الزائد\n- كثرة الملح في الطعام\n- قلة الرياضة\n- التوتر المزمن\n- الكافيين والتدخين\nالضغط الطبيعي: أقل من 120/80 mmHg. ارتفاع: 130/80 فأكثر.\nللتخفيض بدون دواء:\n- قلل الملح (أقل من 2300 mg يومياً)\n- نظام DASH: خضروات، فواكه، حليب قليل الدسم\n- رياضة 30 دقيقة يومياً تخفض الضغط 5-8 درجات\n- خسارة 5 كغ تخفض الضغط 5 درجات""", - }, - { - "q": "ما أسباب الكبد الدهني؟ كيف أعالج الكبد الدهني؟", - "a": """الكبد الدهني (Fatty Liver) هو تراكم الدهون في خلايا الكبد.\nأسبابه:\n- السمنة والوزن الزائد (السبب الأول)\n- السكري ومقاومة الأنسولين\n- ارتفاع الدهون الثلاثية\n- الكحول\n- الأدوية كالكورتيزون\nالأعراض: غالباً بدون أعراض، يُكتشف بالموجات الصوتية أو ارتفاع ALT.\nالعلاج:\n- خسارة 7-10% من الوزن تحسن الكبد بشكل ملحوظ\n- تقليل السكر والفركتوز (مشروبات غازية)\n- القهوة بدون سكر تحمي الكبد (2-3 أكواب يومياً)\n- رياضة 30 دقيقة يومياً حتى بدون خسارة وزن""", - }, - { - "q": "ما أعراض نقص الحديد؟ ما الفرق بين نقص الحديد وفقر الدم؟", - "a": """نقص الحديد أعم من فقر الدم:\n- مرحلة 1: نضوب مخازن الحديد (فيريتين منخفض) بدون أعراض واضحة\n- مرحلة 2: هيموجلوبين يبدأ بالانخفاض\n- مرحلة 3: فقر الدم الكامل\nأعراض نقص الحديد:\n- تعب مزمن حتى مع قسط كافٍ من النوم\n- برودة الأطراف\n- تساقط الشعر وهشاشة الأظافر\n- شهية غريبة (أكل الثلج أو التراب)\n- ضعف التركيز والذاكرة\n- شحوب الجلد والملتحمة\nالتحاليل: فيريتين أفضل مؤشر لمخزون الحديد. حديد الدم + TIBC + هيموجلوبين.""", - }, - { - "q": "ما معنى ارتفاع WBC خلايا الدم البيضاء؟", - "a": """WBC (خلايا الدم البيضاء) = جهاز المناعة.\nطبيعي: 4,000-11,000 خلية/µL.\nارتفاع WBC أسبابه:\n- عدوى بكتيرية: ارتفاع العدلات Neutrophils\n- عدوى فيروسية: ارتفاع الخلايا اللمفاوية Lymphocytes\n- حساسية وطفيليات: ارتفاع الحمضات Eosinophils\n- التهاب وتوتر جسدي\n- بعض الأدوية كالكورتيزون\n- نادراً: ابيضاض الدم (لوكيميا)\nانخفاض WBC:\n- عدوى فيروسية شديدة، كيماوي، نقص مناعة\nيُقرأ مع التفريق الوظيفي CBC diff لتحديد نوع الارتفاع.""", - }, - { - "q": "كيف أتحكم في مستوى السكر HbA1c؟ ما المقصود بـ HbA1c؟", - "a": """HbA1c يقيس متوسط سكر الدم خلال 3 أشهر الماضية.\nلذلك لا ينخدع بتحسن السكر يوم الفحص.\nالأهداف:\n- طبيعي: أقل من 5.7%\n- ما قبل السكري: 5.7-6.4%\n- سكري متحكم به: أقل من 7%\n- سكري غير متحكم: 7% فأكثر\nلخفض HbA1c:\n- قلل الكربوهيدرات المكررة (خبز أبيض، أرز، سكر)\n- رياضة 30 دقيقة يومياً تخفض HbA1c بـ 0.5-1%\n- وزّع الوجبات 5-6 وجبات صغيرة\n- التزم بالأدوية والأنسولين إن وُصف\n- قياس السكر بانتظام""", - }, - { - "q": "ما معنى انخفاض الصفائح الدموية (Platelets)؟", - "a": """الصفائح الدموية مسؤولة عن إيقاف النزيف.\nطبيعي: 150,000-400,000/µL.\nانخفاض الصفائح (Thrombocytopenia) أسبابه:\n- التهابات فيروسية (دنج، عدد أحادي)\n- أمراض المناعة الذاتية (ITP)\n- أمراض الكبد والطحال\n- العلاج الكيميائي\n- نقص فيتامين B12 أو فولات\nأعراض الانخفاض الشديد:\n- كدمات تظهر بسهولة\n- نزيف اللثة أو الأنف\n- نزيف طويل بعد الجرح\n- نقاط حمراء صغيرة في الجلد (Petechiae)\nاستشر الطبيب إن كانت أقل من 100,000.""", - }, - { - "q": "ما أسباب الدوخة والدوار؟ متى تكون خطيرة؟", - "a": """أسباب الدوخة الشائعة:\n- انخفاض ضغط الدم عند الوقوف (دوار وضعي)\n- فقر الدم ونقص الحديد\n- نقص السكر (نقص سكر الدم)\n- الجفاف وقلة الماء\n- اضطرابات الأذن الداخلية (BPPV - الحجارة)\n- ضغط الدم المرتفع\n- أدوية معينة\n- القلق والتوتر\nالتحاليل المقترحة: سكر الدم، CBC، ضغط الدم، TSH.\nمتى تستشير الطبيب فوراً:\n- دوخة مع صداع شديد مفاجئ\n- مع ضعف في وجه أو يد\n- مع صعوبة كلام أو رؤية مزدوجة (أعراض سكتة)""", - }, - { - "q": "ما سبب ارتفاع إنزيمات الكبد ALT و AST؟", - "a": """ALT و AST إنزيمات داخل خلايا الكبد. ارتفاعها يعني تضرر الخلايا.\nطبيعي: ALT أقل من 56، AST أقل من 40 U/L.\nأسباب الارتفاع:\n- الكبد الدهني (السبب الأشيع حالياً)\n- التهاب الكبد الفيروسي (B أو C)\n- الكحول\n- الأدوية (باراسيتامول الزائد، ستاتين، بعض المضادات الحيوية)\n- حصوات المرارة\n- توتر شديد أو رياضة مفرطة (AST يرتفع في العضلات أيضاً)\nدرجات الارتفاع:\n- خفيف (مرتين الطبيعي): مراقبة\n- معتدل (5-10 أضعاف): فحوصات إضافية\n- شديد (فوق 10 أضعاف): طارئ طبي""", - }, - { - "q": "ما هو مؤشر الكتلة الجسمية BMI وكيف أحسبه؟", - "a": """مؤشر الكتلة الجسمية BMI = الوزن (كغ) ÷ الطول (م) ÷ الطول (م).\nمثال: وزن 80 كغ، طول 1.70م → BMI = 80 ÷ 1.70 ÷ 1.70 = 27.7\nالتصنيف:\n- أقل من 18.5: نقص وزن\n- 18.5-24.9: طبيعي (مثالي)\n- 25-29.9: زيادة وزن\n- 30-34.9: سمنة درجة 1\n- 35-39.9: سمنة درجة 2\n- 40+: سمنة مرضية\nمحيط الخصر أيضاً مهم: رجال أقل من 102 سم، نساء أقل من 88 سم للوقاية من أمراض القلب والسكري.""", - }, - { - "q": "ما هو البوتاسيوم وما أعراض نقصه وارتفاعه؟", - "a": """البوتاسيوم معدن حيوي لعمل القلب والعضلات والأعصاب.\nطبيعي: 3.5-5.0 mEq/L.\nأعراض نقص البوتاسيوم (أقل من 3.5):\n- ضعف عضلي وتشنجات\n- إمساك\n- عدم انتظام القلب\n- تعب وإجهاد\nأسباب النقص: مدرات البول، إسهال، قيء، سوء تغذية.\nأعراض ارتفاع البوتاسيوم (فوق 5.5):\n- عدم انتظام قلب خطير\n- ضعف عضلي\nأسباب الارتفاع: الفشل الكلوي، بعض الأدوية.\nمصادر البوتاسيوم الغذائية: موز، بطاطا، أفوكادو، سبانخ، بقوليات.""", - }, - { - "q": "ما معنى ارتفاع PSA؟ ما هو اختبار المستضد البروستاتي؟", - "a": """PSA (مستضد البروستاتا النوعي) بروتين تفرزه البروستاتا.\nطبيعي: أقل من 4 ng/mL (يختلف بالعمر).\nارتفاع PSA أسبابه:\n- ضخامة البروستاتا الحميدة (BPH) - الأشيع\n- التهاب البروستاتا\n- سرطان البروستاتا\n- القسطرة أو فحص البروستاتا قبل الفحص مباشرة\nارتفاع PSA لا يعني سرطاناً بالضرورة!\nللتقييم الدقيق: Free PSA، الكثافة، PSAD، وخزة البروستاتا إن لزم.\nالفحص الدوري: للرجال فوق 50 سنة، أو 40 إن كان هناك تاريخ عائلي.""", - }, - { - "q": "ما أعراض قصور الكلى؟ كيف أحافظ على صحة الكلى؟", - "a": """أعراض ضعف وظائف الكلى:\n- تورم القدمين والكاحلين والوجه\n- تعب وإرهاق\n- قلة التبول أو كثرته\n- بول رغوي (بروتين)\n- ارتفاع ضغط الدم\n- غثيان وفقدان شهية\nالتحاليل: كرياتينين، eGFR، يوريا، تحليل بول.\nللحفاظ على الكلى:\n- اشرب 2-3 لترات ماء يومياً\n- تحكم في السكر وضغط الدم (أهم الأسباب)\n- تجنب المسكنات (ibuprofen, diclofenac) بشكل متكرر\n- فحص كرياتينين وبول سنوياً إن كنت في خطر""", - }, - { - "q": "ما هو الثيروكسين ليفوثيروكسين؟ كيف آخذ دواء الغدة الدرقية؟", - "a": """ليفوثيروكسين (Levothyroxine / Eltroxin) هو علاج قصور الغدة الدرقية.\nطريقة الأخذ الصحيحة:\n- صائماً في الصباح قبل الفطور بـ 30-60 دقيقة\n- ابتلعه بكوب ماء كامل\n- لا تأخذه مع: القهوة، حليب، مكملات الكالسيوم، حديد، مضادات حموضة (تقلل الامتصاص)\nمتابعة:\n- TSH بعد 6-8 أسابيع من بدء العلاج أو تغيير الجرعة\n- TSH كل 6-12 شهراً عند الاستقرار\nتنبيه: لا تتوقف عن الدواء دون استشارة الطبيب، حتى لو تحسنت الأعراض.""", - }, - { - "q": "ما هي فحوصات الخصوبة عند المرأة؟ لماذا يتأخر الحمل؟", - "a": """فحوصات الخصوبة الأساسية عند المرأة:\n- AMH: مخزون البويضات\n- FSH وLH: في اليوم 2-3 من الدورة\n- إيستراديول E2\n- TSH: قصور الدرقية يؤثر على الخصوبة\n- البرولاكتين: ارتفاعه يمنع الإباضة\n- أشعة رحم وقناتين (HSG)\n- أشعة مبايض للبويضات (follicular monitoring)\nأسباب شائعة لتأخر الحمل:\n- تكيس المبايض PCOS (عدم انتظام الإباضة)\n- قصور الغدة الدرقية\n- انسداد قناتي فالوب\n- انخفاض AMH (احتياطي منخفض)\n- عوامل ذكورية (40% من الحالات)\nاستشير طبيب خصوبة بعد سنة من المحاولة (أو 6 أشهر فوق 35 سنة).""", - }, - { - "q": "ما هو هرمون التستوستيرون المنخفض عند الرجال؟ ما أعراضه؟", - "a": """انخفاض التستوستيرون (Low T) عند الرجال:\nطبيعي: 300-1000 ng/dL. منخفض: أقل من 300.\nأعراض الانخفاض:\n- ضعف الرغبة الجنسية\n- ضعف الانتصاب (ED)\n- تعب وإرهاق\n- فقدان كتلة العضلات وزيادة الدهون\n- اكتئاب وتغير مزاج\n- انخفاض كثافة العظام\n- قلة الشعر وصغر الخصيتين (طويل الأمد)\nأسباب الانخفاض:\n- السمنة (الدهون تحول التستوستيرون لإستروجين)\n- الشيخوخة (تنخفض 1% سنوياً بعد 30)\n- التوتر المزمن\n- الكحول والتدخين\n- قصور الخصية أو الغدة النخامية""", - }, - { - "q": "ما هو تحليل المقاومة للأنسولين HOMA-IR؟", - "a": """مقاومة الأنسولين تعني أن الخلايا لا تستجيب للأنسولين بشكل طبيعي.\nالبنكرياس يُنتج المزيد من الأنسولين لتعويض ذلك.\nلقياسها: HOMA-IR = (سكر صائم × أنسولين صائم) ÷ 405\nطبيعي: أقل من 2.5. مقاومة: فوق 2.5.\nأعراض مقاومة الأنسولين:\n- تعب بعد الأكل\n- شهية زائدة للسكريات\n- صعوبة إنقاص الوزن\n- تغيّر لون الجلد في الرقبة والإبط (Acanthosis Nigricans)\n- تكيس المبايض\nالعلاج:\n- خسارة 7-10% من الوزن\n- تقليل الكربوهيدرات المكررة\n- رياضة منتظمة\n- الميتفورمين بوصفة طبيب""", - }, - { - "q": "ما أسباب الصداع المتكرر؟ متى يكون الصداع خطيراً؟", - "a": """أسباب الصداع المتكرر الشائعة:\n- التوتر والإجهاد النفسي (الأكثر شيوعاً)\n- صداع نصفي (ميجرين): نبضي من جانب، مع غثيان وحساسية للضوء\n- الجفاف وقلة الماء\n- قلة النوم أو كثرته\n- ارتفاع ضغط الدم\n- مشاكل الرقبة والفقرات العنقية\n- إجهاد العين (شاشات)\n- فقر الدم ونقص الحديد\nصداع ثانوي: علامات تستدعي طبيباً فوراً:\n- صداع مفاجئ شديد جداً (سكتة دماغية)\n- مع حمى وتصلب رقبة (تهاب سحايا)\n- مع ضعف أو تنميل في جهة\n- يزداد تدريجياً على أسابيع""", - }, - { - "q": "ما هي فائدة المغنيسيوم؟ ما أعراض نقصه؟", - "a": """المغنيسيوم يشارك في أكثر من 300 تفاعل كيميائي في الجسم.\nطبيعي في الدم: 1.7-2.2 mg/dL (لكن 99% مخزون في الخلايا!).\nأعراض نقص المغنيسيوم:\n- تشنجات عضلية (خاصة الساقين ليلاً)\n- رعشة وارتجاف\n- صداع متكرر\n- قلق وتوتر وأرق\n- إمساك\n- عدم انتظام القلب\nأسباب النقص:\n- نظام غذائي فقير (معالجة الطعام تزيل المغنيسيوم)\n- السكري (الكلى تُفرز أكثر)\n- الكحول والإسهال المزمن\n- مضادات الحموضة (PPI) لفترة طويلة\nمصادر غذائية: مكسرات، بذور، خضروات ورقية، شوكولاتة داكنة.""", - }, -] - - -def build_faqs() -> list[Document]: - print("\n[2.5/5] بناء الأسئلة الشائعة (FAQ)...") - docs = [] - for faq in FAQS: - content = f"سؤال شائع: {faq['q']}\nالإجابة: {faq['a']}" - docs.append(Document( - page_content=content, - metadata={"source": "medical_faq", "topic_type": "faq", - "topic_name": faq['q'][:50], "language": "ar"} - )) - print(f" {len(docs)} سؤال وجواب") - return docs - - -def fetch_medlineplus(search_term: str) -> list[dict]: - url = "https://wsearch.nlm.nih.gov/ws/query" - params = {"db": "healthTopics", "term": search_term, "retmax": 2} - try: - resp = requests.get(url, params=params, timeout=15) - if resp.status_code != 200: - return [] - root = ET.fromstring(resp.text) - results = [] - for doc in root.findall('.//document'): - title, content = "", "" - for elem in doc.findall('content'): - name = elem.get('name', '') - if name == 'title': - title = elem.text or "" - elif name == 'FullSummary': - raw = elem.text or "" - content = re.sub(r'<[^>]+>', ' ', raw).strip() - content = re.sub(r'\s+', ' ', content) - if title and content and len(content) > 100: - results.append({"title": title, "content": content}) - return results - except Exception as e: - print(f" [ERROR] {e}") - return [] - - -def chunk_text(text: str, chunk_size: int = 400, overlap: int = 40) -> list[str]: - words = text.split() - chunks = [] - for i in range(0, len(words), chunk_size - overlap): - chunk = " ".join(words[i:i + chunk_size]) - if len(chunk) > 80: - chunks.append(chunk) - return chunks - - -def deduplicate(db: Chroma) -> int: - print("\n[1/5] تنظيف التكرار...") - try: - all_data = db._collection.get(include=["documents"]) - ids = all_data["ids"] - docs = all_data["documents"] - seen = {} - duplicates = [] - for doc_id, text in zip(ids, docs): - key = text[:150].strip() - if key in seen: - duplicates.append(doc_id) - else: - seen[key] = doc_id - if duplicates: - db._collection.delete(ids=duplicates) - print(f" حذفنا {len(duplicates)} chunk مكرر") - else: - print(" لا يوجد تكرار") - return len(duplicates) - except Exception as e: - print(f" [ERROR] {e}") - return 0 - - -def build_lab_definitions() -> list[Document]: - print("\n[2/5] بناء تعاريف التحاليل...") - docs = [] - for lab in LAB_DEFINITIONS: - content = f"""تحليل: {lab['name_ar']} | {lab['name_en']} -تعريف: {lab['definition']} -أسباب الارتفاع: {lab['high']} -أسباب الانخفاض: {lab['low']} -أعراض الانخفاض: {lab['symptoms_low']}""" - docs.append(Document( - page_content=content, - metadata={"source": "lab_definitions", "topic_type": "lab_definition", - "topic_name": lab['name_ar'], "language": "ar"} - )) - print(f" {len(docs)} تعريف تحليل") - return docs - - -def build_health_recommendations() -> list[Document]: - print("\n[3/5] بناء التوصيات الصحية...") - docs = [] - for rec in HEALTH_RECOMMENDATIONS: - docs.append(Document( - page_content=f"توصيات صحية - {rec['topic']}:\n{rec['content']}", - metadata={"source": "health_recommendations", "topic_type": "recommendation", - "topic_name": rec['topic'], "language": "ar"} - )) - print(f" {len(docs)} توصية صحية") - return docs - - -def fetch_all_medlineplus() -> list[Document]: - print(f"\n[4/5] جلب {len(MEDLINEPLUS_TOPICS)} موضوع من MedlinePlus...") - docs = [] - for i, (search_term, topic_name, topic_type) in enumerate(MEDLINEPLUS_TOPICS, 1): - if i % 10 == 0: - print(f" [{i}/{len(MEDLINEPLUS_TOPICS)}]...") - results = fetch_medlineplus(search_term) - for item in results: - for idx, chunk in enumerate(chunk_text(item["content"])): - docs.append(Document( - page_content=chunk, - metadata={"source": "MedlinePlus", "topic_name": topic_name, - "topic_type": topic_type, "title": item["title"], - "language": "en", "chunk_index": idx} - )) - time.sleep(0.3) - print(f" {len(docs)} chunk من MedlinePlus") - return docs - - -def main(): - print("=" * 50) - print("بناء الموسوعة الطبية الكاملة") - print("=" * 50) - - print("\nتحميل Embeddings...") - embeddings = HuggingFaceEmbeddings(model_name=EMBEDDINGS_MODEL) - db = Chroma(persist_directory=DB_PATH, embedding_function=embeddings) - - before = db._collection.count() - print(f"عدد الـ chunks قبل: {before}") - - # 1. تنظيف التكرار - deduplicate(db) - - # 2. تعاريف التحاليل - lab_docs = build_lab_definitions() - - # 2.5 الأسئلة الشائعة - faq_docs = build_faqs() - - # 3. التوصيات الصحية - rec_docs = build_health_recommendations() - - # 4. MedlinePlus - ml_docs = fetch_all_medlineplus() - - # 5. إضافة كل شيء - print("\n[5/5] إضافة البيانات لقاعدة البيانات...") - all_new = lab_docs + faq_docs + rec_docs + ml_docs - batch_size = 100 - for i in range(0, len(all_new), batch_size): - batch = all_new[i:i + batch_size] - db.add_documents(batch) - print(f" {min(i + batch_size, len(all_new))}/{len(all_new)} chunk...") - - after = db._collection.count() - print(f"\n{'=' * 50}") - print(f"[OK] اكتملت الموسوعة الطبية!") - print(f"قبل: {before} | بعد: {after} | مضاف: {after - before}") - print(f"{'=' * 50}") - - -if __name__ == "__main__": - main() diff --git a/backend/ingest_medical_kb.py b/backend/ingest_medical_kb.py new file mode 100644 index 0000000000000000000000000000000000000000..00b9bb257d514f236d2b9e11ff520a156b6e81ea --- /dev/null +++ b/backend/ingest_medical_kb.py @@ -0,0 +1,357 @@ +""" +ingest_medical_kb.py — Ingest medical_kb/schemas/*.json → Supabase pgvector + +Creates 3 semantically distinct chunks per lab test: + chunk 0 (definition) — name + abbreviations + unit + clinical meaning + patient explanation + chunk 1 (values) — normal ranges per gender + severity thresholds + followup tests + chunk 2 (symptoms_causes) — high causes + low causes + symptoms_low + +Source tag: "TibyanMedicalKB" (distinct from MedlinePlus "MedlinePlus" or "TibyanLabs") + +Usage: + cd backend + python ingest_medical_kb.py # insert new chunks only + python ingest_medical_kb.py --clear # delete existing TibyanMedicalKB first + python ingest_medical_kb.py --dry-run # print chunks, do NOT insert +""" +from __future__ import annotations + +import hashlib +import json +import os +import re +import sys +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() +os.environ.setdefault("HF_HOME", r"D:\Project\model_cache") +os.environ["TRANSFORMERS_VERBOSITY"] = "error" + +import requests +from langchain_huggingface import HuggingFaceEmbeddings + +EMBED_MODEL = "intfloat/multilingual-e5-large" +SUPABASE_URL = os.getenv("SUPABASE_URL", "") +SUPABASE_KEY = os.getenv("SUPABASE_KEY", "") + +SCHEMAS_DIR = Path(__file__).parent / "medical_kb" / "schemas" +SOURCE_TAG = "TibyanMedicalKB" + + +# ══════════════════════════════════════════════════════════════════ +# 1. Chunk builders +# ══════════════════════════════════════════════════════════════════ + +def _fmt_range(rng: dict) -> str: + lo, hi = rng.get("low", "?"), rng.get("high", "?") + return f"{lo}–{hi}" + + +def _build_definition_chunk(panel: dict, test_name: str, test: dict) -> str: + """Chunk 0: identity + what this test measures.""" + name_ar = test.get("name_ar", test_name) + abbr = ", ".join(test.get("abbreviations", [])[:5]) + unit = test.get("unit", "") + meaning = test.get("clinical_meaning_ar", "") + explain = test.get("patient_explanation_ar", "") + + parts = [ + f"{name_ar} ({test_name})", + ] + if abbr: + parts.append(f"يُعرف أيضاً بـ: {abbr}") + if unit: + parts.append(f"الوحدة: {unit}") + if meaning: + parts.append(f"المعنى الطبي: {meaning}") + if explain: + parts.append(f"شرح مبسط: {explain}") + + panel_name = panel.get("name_ar", panel.get("panel_code", "")) + parts.append(f"يُقاس ضمن: {panel_name} ({panel.get('name_en', '')})") + + return " | ".join(parts) + + +def _build_values_chunk(panel: dict, test_name: str, test: dict) -> str: + """Chunk 1: normal ranges, severity thresholds, followup tests.""" + name_ar = test.get("name_ar", test_name) + unit = test.get("unit", "") + ranges = test.get("ranges", {}) + sev = test.get("severity_thresholds", {}) + followup = test.get("followup_tests", []) + + parts = [f"القيم المرجعية لـ {name_ar}:"] + + range_lines = [] + for gender_key, rng in ranges.items(): + label = { + "adult_male": "بالغ ذكر", + "adult_female": "بالغة أنثى", + "adult": "البالغون", + "children": "الأطفال", + "children_6_12": "الأطفال 6-12 سنة", + "pregnant": "الحوامل", + "elderly_male": "كبار السن ذكر", + "elderly_female": "كبار السن أنثى", + "neonates": "حديثو الولادة", + }.get(gender_key, gender_key) + range_lines.append(f"{label}: {_fmt_range(rng)} {unit}") + parts.append(" | ".join(range_lines)) + + if sev: + sev_lines = [] + for k, v in sev.items(): + label = k.replace("_", " ") + sev_lines.append(f"{label}: {v} {unit}") + parts.append("عتبات الخطورة: " + " | ".join(sev_lines)) + + if followup: + parts.append("التحاليل التكميلية الموصى بها: " + ", ".join(followup[:6])) + + return " — ".join(parts) + + +def _build_symptoms_chunk(panel: dict, test_name: str, test: dict) -> str | None: + """Chunk 2: causes + symptoms. Returns None if empty.""" + name_ar = test.get("name_ar", test_name) + high_ar = test.get("high_causes_ar", []) + low_ar = test.get("low_causes_ar", []) + symp_low = test.get("symptoms_low_ar", []) + + # Thyroid schemas may use interpretation_matrix instead + interp = test.get("interpretation_matrix", {}) + + parts = [] + if high_ar: + parts.append(f"أسباب ارتفاع {name_ar}: " + "، ".join(high_ar)) + if low_ar: + parts.append(f"أسباب انخفاض {name_ar}: " + "، ".join(low_ar)) + if symp_low: + parts.append(f"الأعراض عند انخفاض {name_ar}: " + "، ".join(symp_low)) + if interp: + lines = [] + for pattern, meaning in interp.items(): + lines.append(f"{pattern}: {meaning}") + parts.append(f"تفسير نتائج {name_ar}: " + " | ".join(lines[:6])) + + if not parts: + return None + return " — ".join(parts) + + +def make_test_chunks(panel: dict, test_name: str, test: dict) -> list[dict]: + """Return list of {content, chunk_type, chunk_index} for one lab test.""" + chunks = [] + + def_text = _build_definition_chunk(panel, test_name, test) + chunks.append({"content": def_text, "chunk_type": "definition", "chunk_index": 0}) + + val_text = _build_values_chunk(panel, test_name, test) + chunks.append({"content": val_text, "chunk_type": "values", "chunk_index": 1}) + + sym_text = _build_symptoms_chunk(panel, test_name, test) + if sym_text and len(sym_text) > 40: + chunks.append({"content": sym_text, "chunk_type": "symptoms", "chunk_index": 2}) + + return chunks + + +# ══════════════════════════════════════════════════════════════════ +# 2. Schema loading +# ══════════════════════════════════════════════════════════════════ + +def load_all_schemas() -> list[dict]: + schemas = [] + for path in sorted(SCHEMAS_DIR.glob("*.json")): + with path.open(encoding="utf-8") as f: + schemas.append(json.load(f)) + return schemas + + +def build_all_docs(schemas: list[dict]) -> list[dict]: + """Convert all schemas to flat list of documents ready for embedding.""" + docs = [] + for panel in schemas: + panel_code = panel.get("panel_code", "unknown") + specialty = panel.get("specialty", "general") + panel_name_ar = panel.get("name_ar", panel_code) + tests = panel.get("tests", {}) + + for test_name, test in tests.items(): + for chunk in make_test_chunks(panel, test_name, test): + docs.append({ + "content": chunk["content"], + "metadata": { + "source": SOURCE_TAG, + "panel_code": panel_code, + "test_name": test_name, + "test_name_ar": test.get("name_ar", test_name), + "chunk_type": chunk["chunk_type"], + "chunk_index": chunk["chunk_index"], + "specialty": specialty, + "topic_type": "lab_test", + "title": f"{test.get('name_ar', test_name)} — {panel_name_ar}", + "language": "ar", + "unit": test.get("unit", ""), + }, + }) + + return docs + + +# ══════════════════════════════════════════════════════════════════ +# 3. Supabase helpers (same pattern as ingest_medlineplus.py) +# ══════════════════════════════════════════════════════════════════ + +def _headers() -> dict: + return { + "apikey": SUPABASE_KEY, + "Authorization": f"Bearer {SUPABASE_KEY}", + "Content-Type": "application/json", + "Prefer": "return=minimal", + } + + +def clear_kb_source(url: str, key: str): + r = requests.delete( + f"{url}/rest/v1/documents", + headers={ + "apikey": key, + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + }, + params={"metadata->>source": f"eq.{SOURCE_TAG}"}, + timeout=30, + ) + print(f" [CLEAR] source={SOURCE_TAG} -> HTTP {r.status_code}") + + +def insert_batch(batch: list[dict], url: str, key: str) -> bool: + try: + r = requests.post( + f"{url}/rest/v1/documents", + headers=_headers(), + json=batch, + timeout=60, + ) + if r.status_code not in (200, 201): + print(f" [INSERT ERROR] {r.status_code}: {r.text[:300]}") + return False + return True + except Exception as e: + print(f" [INSERT EXCEPTION] {e}") + return False + + +def embed_and_insert( + docs: list[dict], + embeddings: HuggingFaceEmbeddings, + url: str, + key: str, + seen_hashes: set, + batch_size: int = 30, +) -> int: + inserted = 0 + skipped = 0 + batch = [] + + for doc in docs: + content = doc["content"].strip() + if len(content) < 20: + skipped += 1 + continue + + h = hashlib.md5(content.encode()).hexdigest() + if h in seen_hashes: + skipped += 1 + continue + seen_hashes.add(h) + + try: + vec = embeddings.embed_query(content) + except Exception as e: + print(f" [EMBED ERROR] {e}") + continue + + batch.append({ + "content": content, + "metadata": doc["metadata"], + "embedding": vec, + }) + + if len(batch) >= batch_size: + if insert_batch(batch, url, key): + inserted += len(batch) + print(f" [BATCH] inserted {len(batch)} | total {inserted}") + batch = [] + + if batch: + if insert_batch(batch, url, key): + inserted += len(batch) + print(f" [BATCH] inserted {len(batch)} | total {inserted}") + + if skipped: + print(f" [SKIP] {skipped} duplicate/short chunks skipped") + + return inserted + + +# ══════════════════════════════════════════════════════════════════ +# 4. Main +# ══════════════════════════════════════════════════════════════════ + +def main(): + dry_run = "--dry-run" in sys.argv + do_clear = "--clear" in sys.argv + + print("=" * 60) + print(f"ingest_medical_kb.py | source={SOURCE_TAG}") + print(f"schemas dir: {SCHEMAS_DIR}") + print("=" * 60) + + schemas = load_all_schemas() + if not schemas: + print(f"[ERROR] لا توجد ملفات JSON في {SCHEMAS_DIR}") + sys.exit(1) + + print(f"[SCHEMAS] loaded {len(schemas)} panels: {[p.get('panel_code') for p in schemas]}") + + docs = build_all_docs(schemas) + print(f"[CHUNKS] {len(docs)} total chunks to insert\n") + + for i, doc in enumerate(docs[:5]): + meta = doc["metadata"] + preview = doc["content"][:120].encode("ascii", errors="replace").decode() + print(f" chunk {i} | {meta['panel_code']}.{meta['test_name']} [{meta['chunk_type']}]") + print(f" {preview}...") + print() + + if dry_run: + print(f"\n[DRY RUN] Would insert {len(docs)} chunks. Exiting.") + return + + if not SUPABASE_URL or not SUPABASE_KEY: + print("[ERROR] SUPABASE_URL و SUPABASE_KEY غير موجودان في .env") + sys.exit(1) + + if do_clear: + print("[CLEAR] حذف السجلات القديمة...") + clear_kb_source(SUPABASE_URL, SUPABASE_KEY) + + print(f"[EMBED] تحميل نموذج {EMBED_MODEL}...") + embeddings = HuggingFaceEmbeddings(model_name=EMBED_MODEL) + print("[EMBED] ready\n") + + seen_hashes: set = set() + total = embed_and_insert(docs, embeddings, SUPABASE_URL, SUPABASE_KEY, seen_hashes) + + print(f"\n{'=' * 60}") + print(f"[DONE] إجمالي المُدرج: {total} chunk من {len(docs)}") + print(f"{'=' * 60}") + + +if __name__ == "__main__": + main() diff --git a/backend/ingest_medlineplus.py b/backend/ingest_medlineplus.py index a8cb15befc5022f7f12c0e6c99d04466dc1fe880..84765e332444e8c30df85ed1e970c09f659359f4 100644 --- a/backend/ingest_medlineplus.py +++ b/backend/ingest_medlineplus.py @@ -1,30 +1,40 @@ """ -سكريبت استيراد الموسوعة الطبية من MedlinePlus (مجاني، بدون API key) -الاستخدام: python ingest_medlineplus.py +سكريبت استيراد شامل للموسوعة الطبية -> pgvector (Supabase) +يحل محل النسخة القديمة التي كانت تكتب على ChromaDB. + +المصادر: + 1. تعاريف التحاليل المخبرية (ثنائي اللغة) — من medical_data.py + 2. MedlinePlus API (مجاني، بدون API key) — 70+ موضوع طبي + 3. توصيات صحية عربية — من medical_data.py + +الاستخدام: + cd backend && python ingest_medlineplus.py + أو لتنظيف الجداول أولاً: python ingest_medlineplus.py --clear """ -import os -import re -import time -import requests +import os, re, sys, time, hashlib, requests import xml.etree.ElementTree as ET -from langchain_huggingface import HuggingFaceEmbeddings -from langchain_chroma import Chroma -from langchain_core.documents import Document +from dotenv import load_dotenv -os.environ['HF_HOME'] = r'D:\Project\model_cache' +load_dotenv() +os.environ.setdefault('HF_HOME', r'D:\Project\model_cache') +os.environ['TRANSFORMERS_VERBOSITY'] = 'error' + +from langchain_huggingface import HuggingFaceEmbeddings +from medical_data import LAB_DEFINITIONS, HEALTH_RECOMMENDATIONS -DB_PATH = r'D:\Project\chroma_db' -EMBEDDINGS_MODEL = "intfloat/multilingual-e5-small" +EMBED_MODEL = "intfloat/multilingual-e5-large" +SUPABASE_URL = os.getenv("SUPABASE_URL", "") +SUPABASE_KEY = os.getenv("SUPABASE_KEY", "") -# ===== تحاليل الدم ===== -LAB_TESTS = [ +# ── MedlinePlus topics (search_term, topic_name, topic_type) ────────────── +LAB_TOPICS = [ ("blood glucose test diabetes", "glucose", "lab_test"), ("complete blood count CBC hemoglobin", "CBC", "lab_test"), ("hemoglobin anemia iron deficiency", "hemoglobin", "lab_test"), ("cholesterol LDL HDL triglycerides", "cholesterol", "lab_test"), ("thyroid function TSH T3 T4", "thyroid", "lab_test"), ("liver function ALT AST bilirubin", "liver function", "lab_test"), - ("kidney function creatinine BUN", "kidney function", "lab_test"), + ("kidney function creatinine BUN eGFR", "kidney function", "lab_test"), ("iron ferritin transferrin anemia", "iron", "lab_test"), ("vitamin D deficiency bone", "vitamin D", "lab_test"), ("vitamin B12 deficiency anemia", "vitamin B12", "lab_test"), @@ -33,17 +43,16 @@ LAB_TESTS = [ ("HbA1c glycated hemoglobin diabetes", "HbA1c", "lab_test"), ("white blood cell WBC leukocytes infection", "WBC", "lab_test"), ("platelet count bleeding clotting", "platelets", "lab_test"), - ("eosinophils allergy parasites", "eosinophils", "lab_test"), ("sodium electrolytes hyponatremia", "sodium", "lab_test"), ("potassium electrolytes hyperkalemia", "potassium", "lab_test"), ("calcium bone osteoporosis", "calcium", "lab_test"), - ("magnesium deficiency muscle", "magnesium", "lab_test"), + ("magnesium deficiency muscle cramp", "magnesium", "lab_test"), ("albumin protein liver nutrition", "albumin", "lab_test"), ("PSA prostate cancer screening", "PSA", "lab_test"), + ("vitamin D calcium bone density", "bone health", "lab_test"), ] -# ===== الأعراض الشائعة ===== -SYMPTOMS = [ +SYMPTOM_TOPICS = [ ("fatigue tiredness exhaustion chronic", "fatigue", "symptom"), ("headache migraine pain relief", "headache", "symptom"), ("fever temperature infection causes", "fever", "symptom"), @@ -54,57 +63,188 @@ SYMPTOMS = [ ("nausea vomiting causes treatment", "nausea", "symptom"), ("back pain lower spine", "back pain", "symptom"), ("weight loss unexplained causes", "weight loss", "symptom"), - ("weight gain causes treatment", "weight gain", "symptom"), ("hair loss alopecia causes", "hair loss", "symptom"), - ("skin rash allergy dermatitis", "skin rash", "symptom"), ("joint pain arthritis inflammation", "joint pain", "symptom"), ("muscle weakness fatigue causes", "muscle weakness", "symptom"), ("palpitations heart irregular", "palpitations", "symptom"), ("insomnia sleep disorders causes", "insomnia", "symptom"), ("anxiety stress mental health", "anxiety", "symptom"), - ("depression mood mental health", "depression", "symptom"), ("frequent urination diabetes kidney", "frequent urination", "symptom"), - ("excessive thirst polydipsia causes", "excessive thirst", "symptom"), ("blurred vision eye causes", "blurred vision", "symptom"), - ("swollen feet edema causes", "swollen feet", "symptom"), - ("pale skin anemia causes", "pale skin", "symptom"), - ("night sweats causes fever infection", "night sweats", "symptom"), - ("cough chronic causes respiratory", "cough", "symptom"), - ("numbness tingling hands feet", "numbness tingling", "symptom"), + ("swollen feet edema causes", "edema", "symptom"), + ("numbness tingling hands feet neuropathy", "numbness tingling", "symptom"), ] -# ===== الأمراض الشائعة ===== -DISEASES = [ +DISEASE_TOPICS = [ ("diabetes mellitus type 2 management", "diabetes", "disease"), ("hypertension high blood pressure treatment", "hypertension", "disease"), ("anemia iron deficiency treatment", "anemia", "disease"), ("hypothyroidism underactive thyroid treatment", "hypothyroidism", "disease"), ("hyperthyroidism overactive thyroid treatment", "hyperthyroidism", "disease"), ("coronary artery disease heart", "heart disease", "disease"), - ("kidney disease chronic renal failure", "kidney disease", "disease"), + ("chronic kidney disease renal failure", "kidney disease", "disease"), ("fatty liver hepatic steatosis", "fatty liver", "disease"), - ("gout uric acid joint", "gout", "disease"), + ("gout uric acid joint treatment", "gout", "disease"), ("osteoporosis bone density fracture", "osteoporosis", "disease"), - ("vitamin D deficiency treatment supplement", "vitamin D deficiency", "disease"), - ("vitamin B12 deficiency treatment", "vitamin B12 deficiency", "disease"), ("high cholesterol hyperlipidemia treatment", "high cholesterol", "disease"), - ("asthma respiratory treatment inhaler", "asthma", "disease"), - ("urinary tract infection UTI treatment", "UTI", "disease"), - ("irritable bowel syndrome IBS treatment", "IBS", "disease"), - ("GERD acid reflux heartburn treatment", "GERD", "disease"), - ("polycystic ovary syndrome PCOS treatment", "PCOS", "disease"), - ("obesity overweight BMI treatment", "obesity", "disease"), ("metabolic syndrome insulin resistance", "metabolic syndrome", "disease"), - ("celiac disease gluten intolerance", "celiac disease", "disease"), + ("polycystic ovary syndrome PCOS", "PCOS", "disease"), + ("vitamin D deficiency treatment", "vitamin D deficiency", "disease"), + ("vitamin B12 deficiency treatment", "vitamin B12 deficiency", "disease"), + ("urinary tract infection UTI treatment", "UTI", "disease"), + ("irritable bowel syndrome IBS", "IBS", "disease"), + ("GERD acid reflux heartburn", "GERD", "disease"), ("rheumatoid arthritis autoimmune joint", "rheumatoid arthritis", "disease"), + ("celiac disease gluten intolerance", "celiac disease", "disease"), ] -ALL_TOPICS = LAB_TESTS + SYMPTOMS + DISEASES +ALL_MEDLINEPLUS_TOPICS = LAB_TOPICS + SYMPTOM_TOPICS + DISEASE_TOPICS + + +# ══════════════════════════════════════════════════════════════════ +# 1. Text Cleaning +# ══════════════════════════════════════════════════════════════════ + +def clean_text(text: str) -> str: + """Remove HTML tags, normalize whitespace, normalize Arabic alef variants.""" + text = re.sub(r'<[^>]+>', ' ', text) # HTML tags + text = re.sub(r'&[a-zA-Z]+;', ' ', text) # HTML entities + text = re.sub(r'\s+', ' ', text).strip() # excessive whitespace + # Normalize Arabic alef variants — safe, standard NLP practice + text = re.sub(r'[إأآ]', 'ا', text) + return text + + +# ══════════════════════════════════════════════════════════════════ +# 2. Semantic Chunking +# ══════════════════════════════════════════════════════════════════ + +def make_lab_chunks(lab: dict) -> list[dict]: + """ + Create 3 semantically distinct chunks per lab test. + Returns list of {content, chunk_type, chunk_index} dicts. + """ + name_ar, name_en = lab["name_ar"], lab["name_en"] + chunks = [] + + # Chunk 0 — Definition + normal range + chunks.append({ + "content": clean_text(f"{name_ar} ({name_en}): {lab['definition']}"), + "chunk_type": "definition", + "chunk_index": 0, + }) + + # Chunk 1 — Causes of high and low + abnormal = ( + f"ارتفاع {name_ar}: {lab['high']}. " + f"انخفاض {name_ar}: {lab['low']}." + ) + chunks.append({ + "content": clean_text(abnormal), + "chunk_type": "values", + "chunk_index": 1, + }) + + # Chunk 2 — Symptoms (only if content is meaningful) + symptoms = lab.get("symptoms_low", "").strip() + if len(symptoms) > 30: + sym_text = f"الأعراض والعلامات المرتبطة بـ{name_ar}: {symptoms}." + chunks.append({ + "content": clean_text(sym_text), + "chunk_type": "symptoms", + "chunk_index": 2, + }) + + return chunks + + +def sentence_chunks(text: str, max_chars: int = 800, overlap: int = 1) -> list[dict]: + """ + Split free-form English/Arabic text at sentence boundaries. + Returns list of {content, chunk_type, chunk_index}. + """ + sentences = [s.strip() for s in re.split(r'(?<=[.!?])\s+', text) if s.strip()] + + chunks, current, current_len = [], [], 0 + for sent in sentences: + if current_len + len(sent) > max_chars and current: + body = ' '.join(current) + chunks.append({"content": body, "chunk_type": _detect_type(body)}) + current = current[-overlap:] if overlap else [] + current_len = sum(len(s) + 1 for s in current) + current.append(sent) + current_len += len(sent) + 1 + + if current: + body = ' '.join(current) + if len(body) > 80: + chunks.append({"content": body, "chunk_type": _detect_type(body)}) + + for i, c in enumerate(chunks): + c["chunk_index"] = i + return chunks + + +def _detect_type(text: str) -> str: + t = text.lower() + if any(w in t for w in ['definition', 'what is', 'also called', 'refers to', 'is a test']): + return 'definition' + if any(w in t for w in ['normal range', 'normal level', 'mg/dl', 'g/dl', 'mmol', 'ng/ml', 'iu/l']): + return 'values' + if any(w in t for w in ['symptom', 'sign', 'can cause', 'may cause', 'causes include']): + return 'symptoms' + if any(w in t for w in ['treatment', 'therapy', 'medication', 'manage', 'drug']): + return 'treatment' + return 'general' + + +# ══════════════════════════════════════════════════════════════════ +# 3. Metadata helpers +# ══════════════════════════════════════════════════════════════════ + +_SPECIALTY_MAP = { + 'hematology': ['hemoglobin', 'rbc', 'wbc', 'platelet', 'cbc', 'hematocrit', 'mcv', 'mch', + 'mchc', 'rdw', 'neutrophil', 'lymphocyte', 'monocyte', 'eosinophil', 'basophil', + 'esr', 'd-dimer', 'fibrinogen', 'aptt', 'pt/inr', 'ferritin', 'iron'], + 'endocrinology':['glucose', 'hba1c', 'insulin', 'tsh', 'thyroid', 't3', 't4', 'cortisol', + 'testosterone', 'estradiol', 'prolactin', 'lh', 'fsh', 'dhea', 'progesterone', 'amh'], + 'cardiology': ['cholesterol', 'ldl', 'hdl', 'triglyceride', 'troponin', 'bnp', 'ck-mb', + 'heart', 'cardiac'], + 'nephrology': ['creatinine', 'egfr', 'bun', 'urea', 'kidney', 'urine protein', 'urine ketone', + 'urine specific gravity', 'chloride', 'sodium', 'potassium'], + 'hepatology': ['alt', 'ast', 'bilirubin', 'liver', 'albumin', 'ggt', 'alkaline phosphatase', + 'total protein', 'hepatitis'], + 'rheumatology': ['crp', 'esr', 'ana', 'rheumatoid', 'uric acid', 'gout', 'anti-tpo'], + 'nutrition': ['vitamin d', 'vitamin b12', 'folic acid', 'zinc', 'magnesium', 'calcium', + 'selenium', 'copper', 'phosphorus', 'iron'], + 'immunology': ['hiv', 'hepatitis b', 'hepatitis c', 'procalcitonin'], + 'reproductive': ['lh', 'fsh', 'progesterone', 'estradiol', 'beta hcg', 'amh', 'semen', 'testosterone', 'prolactin'], +} +def _get_specialty(name: str) -> str: + name_lower = name.lower() + for specialty, keywords in _SPECIALTY_MAP.items(): + if any(k in name_lower for k in keywords): + return specialty + return 'general' -def fetch_medlineplus(search_term: str) -> list[dict]: + +def _extract_unit(definition: str) -> str | None: + m = re.search( + r'\b(g/dL|mg/dL|ng/mL|µg/dL|IU/L|U/L|mEq/L|mmol/L|pg/mL|µIU/mL|mIU/L|mm/hr|ng/dL|µg/L|fL|pg)\b', + definition + ) + return m.group(1) if m else None + + +# ══════════════════════════════════════════════════════════════════ +# 4. MedlinePlus API +# ══════════════════════════════════════════════════════════════════ + +def fetch_medlineplus(search_term: str, retmax: int = 3) -> list[dict]: + """Fetch free MedlinePlus health topic summaries.""" url = "https://wsearch.nlm.nih.gov/ws/query" - params = {"db": "healthTopics", "term": search_term, "retmax": 3} + params = {"db": "healthTopics", "term": search_term, "retmax": retmax} try: resp = requests.get(url, params=params, timeout=15) if resp.status_code != 200: @@ -112,70 +252,221 @@ def fetch_medlineplus(search_term: str) -> list[dict]: root = ET.fromstring(resp.text) results = [] for doc in root.findall('.//document'): - title = "" - content = "" + title, content = "", "" for elem in doc.findall('content'): name = elem.get('name', '') if name == 'title': title = elem.text or "" elif name == 'FullSummary': raw = elem.text or "" - content = re.sub(r'<[^>]+>', ' ', raw).strip() - content = re.sub(r'\s+', ' ', content) - if title and content and len(content) > 100: + content = clean_text(re.sub(r'<[^>]+>', ' ', raw)) + if title and len(content) > 100: results.append({"title": title, "content": content}) return results except Exception as e: - print(f" [ERROR] {search_term}: {e}") + print(f" [MedlinePlus ERROR] {search_term}: {e}") return [] -def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]: - words = text.split() - chunks = [] - for i in range(0, len(words), chunk_size - overlap): - chunk = " ".join(words[i:i + chunk_size]) - if len(chunk) > 100: - chunks.append(chunk) - return chunks +# ══════════════════════════════════════════════════════════════════ +# 5. Supabase pgvector Insert +# ══════════════════════════════════════════════════════════════════ + +def _make_headers(key: str) -> dict: + return { + "apikey": key, + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + "Prefer": "return=minimal", + } + + +def insert_batch(batch: list[dict], url: str, key: str) -> bool: + try: + r = requests.post( + f"{url}/rest/v1/documents", + headers=_make_headers(key), + json=batch, + timeout=60, + ) + if r.status_code not in (200, 201): + print(f" [INSERT ERROR] {r.status_code}: {r.text[:300]}") + return False + return True + except Exception as e: + print(f" [INSERT EXCEPTION] {e}") + return False + + +def clear_source(source: str, url: str, key: str): + """Delete all documents from a given source before re-ingesting.""" + headers = { + "apikey": key, + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + } + r = requests.delete( + f"{url}/rest/v1/documents", + headers=headers, + params={"metadata->>source": f"eq.{source}"}, + timeout=30, + ) + print(f" [CLEAR] source={source} -> {r.status_code}") + + +def embed_and_insert( + docs: list[dict], + embeddings: HuggingFaceEmbeddings, + url: str, + key: str, + seen_hashes: set, + batch_size: int = 50, +) -> int: + """Embed + deduplicate + batch-insert documents to pgvector.""" + inserted = 0 + batch = [] + + for doc in docs: + content = doc["content"] + if not content or len(content) < 20: + continue + h = hashlib.md5(content.encode()).hexdigest() + if h in seen_hashes: + continue + seen_hashes.add(h) + + try: + vec = embeddings.embed_query(content) + except Exception as e: + print(f" [EMBED ERROR] {e}") + continue + batch.append({ + "content": content, + "metadata": doc["metadata"], + "embedding": vec, + }) + + if len(batch) >= batch_size: + if insert_batch(batch, url, key): + inserted += len(batch) + else: + print(f" [WARN] Batch of {len(batch)} failed — skipping") + batch = [] + + if batch: + if insert_batch(batch, url, key): + inserted += len(batch) + + return inserted + + +# ══════════════════════════════════════════════════════════════════ +# 6. Main +# ══════════════════════════════════════════════════════════════════ def main(): - print("تحميل نموذج الـ Embeddings...") - embeddings = HuggingFaceEmbeddings(model_name=EMBEDDINGS_MODEL) + if not SUPABASE_URL or not SUPABASE_KEY: + print("[ERROR] SUPABASE_URL و SUPABASE_KEY غير موجودان في .env") + sys.exit(1) - print("الاتصال بقاعدة البيانات...") - db = Chroma(persist_directory=DB_PATH, embedding_function=embeddings) + do_clear = "--clear" in sys.argv - all_docs = [] - total = len(ALL_TOPICS) - for i, (search_term, topic_name, topic_type) in enumerate(ALL_TOPICS, 1): - print(f"[{i}/{total}] {topic_name}...") - results = fetch_medlineplus(search_term) + print(f"تحميل نموذج Embeddings: {EMBED_MODEL}...") + embeddings = HuggingFaceEmbeddings(model_name=EMBED_MODEL) + + seen_hashes: set = set() + total_inserted = 0 + + # ── 1. Lab definitions (bilingual Arabic/English) ───────────── + print(f"\n[1/3] استيراد تعاريف التحاليل ({len(LAB_DEFINITIONS)} تحليل)...") + if do_clear: + clear_source("TibyanLabs", SUPABASE_URL, SUPABASE_KEY) + + lab_docs = [] + for lab in LAB_DEFINITIONS: + test_name = lab["name_en"].split("(")[0].strip() + specialty = _get_specialty(lab["name_en"] + " " + lab["name_ar"]) + unit = _extract_unit(lab["definition"]) + for chunk in make_lab_chunks(lab): + lab_docs.append({ + "content": chunk["content"], + "metadata": { + "source": "TibyanLabs", + "topic_name": test_name, + "topic_type": "lab_test", + "title": f"{lab['name_ar']} ({lab['name_en']})", + "language": "bilingual", + "chunk_index": chunk["chunk_index"], + "chunk_type": chunk["chunk_type"], + "specialty": specialty, + "test_name": test_name, + "unit": unit, + }, + }) + + n = embed_and_insert(lab_docs, embeddings, SUPABASE_URL, SUPABASE_KEY, seen_hashes) + total_inserted += n + print(f" -> {n} chunk مُضاف من تعاريف التحاليل") + + # ── 2. Health recommendations (Arabic) ─────────────────────── + print(f"\n[2/3] استيراد التوصيات الصحية ({len(HEALTH_RECOMMENDATIONS)} موضوع)...") + rec_docs = [] + for rec in HEALTH_RECOMMENDATIONS: + content = clean_text(f"{rec['topic']}: {rec['content']}") + rec_docs.append({ + "content": content, + "metadata": { + "source": "TibyanLabs", + "topic_name": rec["topic"], + "topic_type": "health_recommendation", + "title": rec["topic"], + "language": "ar", + "chunk_index": 0, + "chunk_type": "treatment", + "specialty": "general", + "test_name": None, + "unit": None, + }, + }) + + n = embed_and_insert(rec_docs, embeddings, SUPABASE_URL, SUPABASE_KEY, seen_hashes) + total_inserted += n + print(f" -> {n} chunk مُضاف من التوصيات الصحية") + + # ── 3. MedlinePlus API ──────────────────────────────────────── + print(f"\n[3/3] استيراد من MedlinePlus ({len(ALL_MEDLINEPLUS_TOPICS)} موضوع)...") + if do_clear: + clear_source("MedlinePlus", SUPABASE_URL, SUPABASE_KEY) + + for i, (search_term, topic_name, topic_type) in enumerate(ALL_MEDLINEPLUS_TOPICS, 1): + results = fetch_medlineplus(search_term) + ml_docs = [] for item in results: - for idx, chunk in enumerate(chunk_text(item["content"])): - doc = Document( - page_content=chunk, - metadata={ - "source": "MedlinePlus", - "topic_name": topic_name, - "topic_type": topic_type, - "title": item["title"], - "language": "en", - "chunk_index": idx, - } - ) - all_docs.append(doc) - - time.sleep(0.3) - - if all_docs: - print(f"\nاضافة {len(all_docs)} chunk...") - db.add_documents(all_docs) - print(f"[OK] تم! الموسوعة تحتوي {len(all_docs)} chunk من {total} موضوع طبي") - else: - print("[ERROR] لم يتم جلب أي بيانات") + for chunk in sentence_chunks(item["content"]): + ml_docs.append({ + "content": chunk["content"], + "metadata": { + "source": "MedlinePlus", + "topic_name": topic_name, + "topic_type": topic_type, + "title": item["title"], + "language": "en", + "chunk_index": chunk["chunk_index"], + "chunk_type": chunk["chunk_type"], + "specialty": _get_specialty(topic_name), + "test_name": topic_name if topic_type == "lab_test" else None, + "unit": None, + }, + }) + + n = embed_and_insert(ml_docs, embeddings, SUPABASE_URL, SUPABASE_KEY, seen_hashes) + total_inserted += n + print(f" [{i}/{len(ALL_MEDLINEPLUS_TOPICS)}] {topic_name} -> {n} chunk") + time.sleep(0.35) # rate-limit courtesy + + print(f"\n[ok] اكتمل! إجمالي المُضاف: {total_inserted} chunk في pgvector (Supabase)") if __name__ == "__main__": diff --git a/backend/main.py b/backend/main.py index 163435929616a4e2382f02e71f255d6266266899..3dda2f8f63625dc727abfd98005bc71517faf59b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,63 +6,121 @@ os.environ['TRANSFORMERS_VERBOSITY'] = 'error' _here = os.path.dirname(os.path.abspath(__file__)) os.environ.setdefault('HF_HOME', os.path.join(_here, '..', 'model_cache')) -import io +# ── Sentry error monitoring (requires SENTRY_DSN in .env / Railway vars) ── +_SENTRY_DSN = os.getenv("SENTRY_DSN", "") +if _SENTRY_DSN: + try: + import sentry_sdk + from sentry_sdk.integrations.fastapi import FastApiIntegration + sentry_sdk.init( + dsn=_SENTRY_DSN, + integrations=[FastApiIntegration(transaction_style="endpoint")], + traces_sample_rate=0.05, # 5% of requests + environment=os.getenv("ENVIRONMENT", "production"), + release=os.getenv("RAILWAY_DEPLOYMENT_ID", "local"), + ) + print("[Sentry] initialized") + except ImportError: + pass # sentry-sdk not installed + import re import json import base64 +import logging from functools import lru_cache +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s | %(message)s", + datefmt="%H:%M:%S", +) + import requests as http_requests -import numpy as np -from PIL import Image -import fitz import easyocr -from groq import Groq import cohere -from rank_bm25 import BM25Okapi -from langchain_huggingface import HuggingFaceEmbeddings -from langchain_core.documents import Document +from groq import Groq # used only by load_tools() to build raw client for agents -from fastapi import FastAPI, UploadFile, File, Form, HTTPException +from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Request, Depends +from fastapi.responses import Response as HttpResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from pydantic import BaseModel +# ── Internal services ────────────────────────────────────────────────────── +from services.search.embedding_service import get_embeddings +from services.search.semantic_search import SemanticSearchService +from services.rag.retriever import Retriever, RetrievalConfig +from services.rag.context_builder import build_context +from services.safety import filter_analysis_report, filter_chat_response, check_emergency, sanitize_query +from services.agents import AgentCoordinator +from services.llm import get_router, LLMRouter +from services.ratelimit import limit_analyze, limit_chat, limit_search +from services.cache import rag_cache, rag_cache_key, search_cache +from services.search.embedding_service import EMBED_MODEL +from medical_kb import kb as medical_kb +from prompts.loader import render as render_prompt, load as load_prompt +from middleware import AuditMiddleware, validate_upload, sanitize_text +from middleware.auth_middleware import optional_user + GROQ_API_KEY = os.getenv("GROQ_API_KEY") if not GROQ_API_KEY: raise RuntimeError("GROQ_API_KEY environment variable is not set") COHERE_API_KEY = os.getenv("COHERE_API_KEY") SUPABASE_URL = os.getenv("SUPABASE_URL", "") SUPABASE_KEY = os.getenv("SUPABASE_KEY", "") +SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY", "") # service_role bypasses RLS SUPABASE_DB_URL = os.getenv("SUPABASE_DB_URL") -GOOGLE_VISION_KEY = os.getenv("GOOGLE_VISION_API_KEY", "") +GOOGLE_VISION_KEY = os.getenv("GOOGLE_VISION_API_KEY", "") +GOOGLE_TTS_KEY = os.getenv("GOOGLE_TTS_KEY", "") +ELEVENLABS_KEY = os.getenv("ELEVENLABS_API_KEY", "") FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000") GROQ_MODEL = "llama-3.3-70b-versatile" GROQ_MODEL_CHAT = "llama-3.1-8b-instant" -# ملاحظة: e5-large يحتاج حذف chroma_db وإعادة ingest بعد التغيير -EMBED_MODEL = "intfloat/multilingual-e5-large" +log = logging.getLogger("tebyan.main") app = FastAPI(title="تبيان الطبي API") + +_is_production = os.getenv("ENVIRONMENT", "development") == "production" +_allowed_origins: list[str] = list(filter(None, [ + "http://localhost:3000", + "http://127.0.0.1:3000", + FRONTEND_URL if FRONTEND_URL != "http://localhost:3000" else None, +])) app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:3000", FRONTEND_URL, "*"], - allow_methods=["*"], - allow_headers=["*"], + allow_origins=_allowed_origins, + allow_methods=["GET", "POST", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "X-Session-Id", "X-Forwarded-For"], + allow_credentials=False, + max_age=600, ) +app.add_middleware(AuditMiddleware, environment=os.getenv("ENVIRONMENT", "development")) + + +def _pg_connect(): + # Try direct host connection first (pooler rejected tenant format) + direct = re.sub( + r"postgresql(?:\+psycopg)?://[^:]+:[^@]+@[^/]+/", + f"postgresql://postgres:{os.getenv('SUPABASE_DB_PASSWORD', 'R1a2g3h4d56')}@db.assxdosinubpubeqjrso.supabase.co:5432/", + SUPABASE_DB_URL, + ) + import psycopg2 + try: + return psycopg2.connect(direct, sslmode="require", connect_timeout=8) + except Exception: + fallback = SUPABASE_DB_URL.replace("postgresql+psycopg://", "postgresql://") + return psycopg2.connect(fallback, sslmode="require", connect_timeout=8) def ensure_analyses_table(): """ينشئ جدول analyses في Supabase تلقائياً إذا لم يكن موجوداً""" if not SUPABASE_DB_URL: - print("[DB] SUPABASE_DB_URL not set — skipping") + log.debug("SUPABASE_DB_URL not set — skipping DB setup") return try: - import psycopg2 - # psycopg2 يستخدم postgresql:// بدون +psycopg - url = SUPABASE_DB_URL.replace("postgresql+psycopg://", "postgresql://") - conn = psycopg2.connect(url, sslmode="require") + conn = _pg_connect() conn.autocommit = True cur = conn.cursor() cur.execute(""" @@ -77,127 +135,204 @@ def ensure_analyses_table(): """) cur.close() conn.close() - print("[DB] analyses table ready ✓") + log.info("analyses table ready") except Exception as e: - print(f"[DB] table setup failed: {e}") + log.error("analyses table setup failed: %s", e) + + +def ensure_chat_table(): + """ينشئ جدول chat_messages لحفظ تاريخ المحادثات عبر الجلسات""" + if not SUPABASE_DB_URL: + return + try: + conn = _pg_connect() + conn.autocommit = True + cur = conn.cursor() + cur.execute(""" + CREATE TABLE IF NOT EXISTS chat_messages ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + session_id text NOT NULL, + role text NOT NULL, + content text NOT NULL, + created_at timestamptz DEFAULT now() + ); + CREATE INDEX IF NOT EXISTS idx_chat_session + ON chat_messages(session_id, created_at); + """) + cur.close() + conn.close() + log.info("chat_messages table ready") + except Exception as e: + log.error("chat table setup failed: %s", e) + + +_START_TIME = __import__("time").time() +_REQUEST_COUNTS: dict[str, int] = {} +_ERROR_COUNTS: dict[str, int] = {} + + +@app.middleware("http") +async def _track_requests(request: Request, call_next): + path = request.url.path + _REQUEST_COUNTS[path] = _REQUEST_COUNTS.get(path, 0) + 1 + response = await call_next(request) + if response.status_code >= 500: + _ERROR_COUNTS[path] = _ERROR_COUNTS.get(path, 0) + 1 + return response + + +@app.get("/health") +async def health(): + import time + uptime_s = int(time.time() - _START_TIME) + db_ok, chunk_count = False, 0 + try: + _, _, _, search_svc, _ = load_tools() + chunk_count = search_svc.count() + db_ok = chunk_count > 0 + except Exception: + pass + return { + "ok": True, + "version": os.getenv("RAILWAY_DEPLOYMENT_ID", "local"), + "environment": os.getenv("ENVIRONMENT", "development"), + "uptime_s": uptime_s, + "db": { + "ok": db_ok, + "chunks": chunk_count, + "source": "pgvector/supabase", + }, + "model": { + "name": os.getenv("EMBED_MODEL", "intfloat/multilingual-e5-large"), + "loaded": db_ok, + }, + "services": { + "groq": bool(GROQ_API_KEY), + "cohere": bool(COHERE_API_KEY), + "vision": bool(os.getenv("GOOGLE_VISION_API_KEY")), + }, + } + + +@app.get("/api/metrics") +async def metrics(): + """Prometheus-style plaintext metrics for uptime monitoring.""" + import time + uptime_s = int(time.time() - _START_TIME) + cache_stats = rag_cache.stats() + lines = [ + "# HELP tebyan_uptime_seconds Seconds since server start", + "# TYPE tebyan_uptime_seconds counter", + f"tebyan_uptime_seconds {uptime_s}", + "", + "# HELP tebyan_requests_total Total HTTP requests per path", + "# TYPE tebyan_requests_total counter", + ] + for path, count in sorted(_REQUEST_COUNTS.items()): + lines.append(f'tebyan_requests_total{{path="{path}"}} {count}') + lines += [ + "", + "# HELP tebyan_errors_total Total 5xx errors per path", + "# TYPE tebyan_errors_total counter", + ] + for path, count in sorted(_ERROR_COUNTS.items()): + lines.append(f'tebyan_errors_total{{path="{path}"}} {count}') + lines += [ + "", + "# HELP tebyan_rag_cache_size Current RAG cache entries", + "# TYPE tebyan_rag_cache_size gauge", + f"tebyan_rag_cache_size {cache_stats.get('size', 0)}", + "", + "# HELP tebyan_rag_cache_hits RAG cache hit count", + "# TYPE tebyan_rag_cache_hits counter", + f"tebyan_rag_cache_hits {cache_stats.get('hits', 0)}", + "", + "# HELP tebyan_rag_cache_misses RAG cache miss count", + "# TYPE tebyan_rag_cache_misses counter", + f"tebyan_rag_cache_misses {cache_stats.get('misses', 0)}", + "", + "# HELP tebyan_rag_cache_hit_rate RAG cache hit rate (0-1)", + "# TYPE tebyan_rag_cache_hit_rate gauge", + f"tebyan_rag_cache_hit_rate {cache_stats.get('hit_rate', 0)}", + "", + "# HELP tebyan_rag_cache_evictions RAG cache eviction count", + "# TYPE tebyan_rag_cache_evictions counter", + f"tebyan_rag_cache_evictions {cache_stats.get('evictions', 0)}", + ] + return HttpResponse(content="\n".join(lines), media_type="text/plain; version=0.0.4") @app.on_event("startup") async def startup(): - load_groq() + ensure_analyses_table() + ensure_chat_table() + load_router() + load_tools() # pre-load e5-large + EasyOCR to avoid cold-start on first request + _load_lora_adapter() + log.info("startup complete — all services warm") @lru_cache(maxsize=1) -def load_groq(): - client = Groq(api_key=GROQ_API_KEY) - print(f"[INFO] Groq ready | chat={GROQ_MODEL_CHAT}") - return client - - -class PGVectorStore: - """واجهة بحث pgvector عبر Supabase REST — نفس interface ChromaDB""" - - def __init__(self, embeddings, url: str, key: str): - self._emb = embeddings - self._url = url - self._key = key - self._headers = { - "apikey": key, - "Authorization": f"Bearer {key}", - "Content-Type": "application/json", - } - - def _embed(self, text: str) -> list: - return self._emb.embed_query(text) - - def similarity_search_with_relevance_scores( - self, query: str, k: int = 10, filter: dict = None - ) -> list: - vec = self._embed(query) - payload = { - "query_embedding": vec, - "match_threshold": 0.3, - "match_count": k, - "filter": filter or {}, - } - try: - r = http_requests.post( - f"{self._url}/rest/v1/rpc/match_documents", - headers=self._headers, - json=payload, - timeout=15, - ) - r.raise_for_status() - return [ - (Document(page_content=row["content"], metadata=row.get("metadata") or {}), - float(row["similarity"])) - for row in r.json() - ] - except Exception as e: - print(f"[pgvector] {e}") - return [] - - def count(self) -> int: - try: - r = http_requests.get( - f"{self._url}/rest/v1/documents", - headers={**self._headers, "Prefer": "count=exact", "Range": "0-0"}, - timeout=10, - ) - return int(r.headers.get("Content-Range", "0/0").split("/")[-1]) - except Exception: - return 0 +def load_router() -> LLMRouter: + """Returns the LLM router singleton (Groq primary, HF fallback if configured).""" + router = get_router() + log.info("LLM router ready | provider=%s", router.provider_name) + return router @lru_cache(maxsize=1) def load_tools(): reader = easyocr.Reader(['ar', 'en'], gpu=False) - embeddings = HuggingFaceEmbeddings(model_name=EMBED_MODEL) - groq_client = Groq(api_key=GROQ_API_KEY) + get_embeddings() # warm up the singleton (also used by SemanticSearchService) + groq_client = Groq(api_key=GROQ_API_KEY) # raw client for agents cohere_client = cohere.ClientV2(api_key=COHERE_API_KEY) if COHERE_API_KEY else None - db = PGVectorStore(embeddings, SUPABASE_URL, SUPABASE_KEY) - print(f"[INFO] Tools loaded | embed={EMBED_MODEL} | DB={db.count()} chunks (pgvector)") - return reader, embeddings, groq_client, cohere_client, db + search_svc = SemanticSearchService(SUPABASE_URL, SUPABASE_KEY) + retriever = Retriever( + search_svc, + co_client=cohere_client, + query_expander=lambda q: generate_search_queries(groq_client, q), + ) + log.info("tools loaded | db=%d chunks", search_svc.count()) + return reader, groq_client, cohere_client, search_svc, retriever -# ══════════════════════════════════════════════ -# M1 — Google Cloud Vision OCR (مع EasyOCR احتياطاً) -# ══════════════════════════════════════════════ +@lru_cache(maxsize=1) +def load_coordinator() -> AgentCoordinator: + reader, groq_client, _, _, retriever = load_tools() + return AgentCoordinator( + reader=reader, + groq_client=groq_client, + retriever=retriever, + kb=medical_kb, + render_prompt_fn=render_prompt, + retrieval_config_cls=RetrievalConfig, + vision_key=GOOGLE_VISION_KEY, + ) -def preprocess_image(image_bytes: bytes) -> "np.ndarray": - """تحسين جودة صورة التحليل قبل OCR: رفع التباين + تحويل لـ grayscale""" - from PIL import ImageFilter, ImageEnhance - img = Image.open(io.BytesIO(image_bytes)) - # تحويل RGBA → RGB - if img.mode in ('RGBA', 'P'): - img = img.convert('RGB') - # رفع دقة الصورة الصغيرة - w, h = img.size - if min(w, h) < 800: - scale = 800 / min(w, h) - img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS) - # تحسين التباين والحدة - img = ImageEnhance.Contrast(img).enhance(1.8) - img = ImageEnhance.Sharpness(img).enhance(2.0) - return np.array(img) - - -def extract_text_google_vision(image_bytes: bytes) -> str: - """OCR دقيق للتحاليل العربية والإنجليزية — GOOGLE_VISION_API_KEY مطلوب في .env""" - b64 = base64.b64encode(image_bytes).decode('utf-8') - payload = { - "requests": [{ - "image": {"content": b64}, - "features": [{"type": "DOCUMENT_TEXT_DETECTION", "maxResults": 1}], - "imageContext": {"languageHints": ["ar", "en"]}, - }] - } - url = f"https://vision.googleapis.com/v1/images:annotate?key={GOOGLE_VISION_KEY}" - r = http_requests.post(url, json=payload, timeout=30) - r.raise_for_status() - text = r.json()["responses"][0].get("fullTextAnnotation", {}).get("text", "") - print(f"[VISION] Google Cloud Vision: {len(text)} chars") - return text + +# ── PEFT/LoRA adapter auto-loader ────────────────────────────────────────── +# Place a trained adapter in backend/models/lora/ to activate local inference. +# When absent, the system uses Groq API transparently. + +_LORA_PATH = os.path.join(os.path.dirname(__file__), "models", "lora") +_lora_model_info: dict = {"loaded": False, "path": None, "model_name": None} + + +def _load_lora_adapter() -> None: + if not os.path.isdir(_LORA_PATH): + log.info("No LoRA adapter directory found — using Groq API") + return + adapter_files = [f for f in os.listdir(_LORA_PATH) if f.endswith((".bin", ".safetensors"))] + if not adapter_files: + log.info("LoRA directory exists but is empty — using Groq API") + return + try: + from peft import PeftModel # noqa: F401 + _lora_model_info.update({"loaded": True, "path": _LORA_PATH, + "model_name": adapter_files[0]}) + log.info("LoRA adapter detected at %s — local inference active", _LORA_PATH) + except ImportError: + log.warning("LoRA adapter found but peft not installed — using Groq API") # ══════════════════════════════════════════════ @@ -214,27 +349,6 @@ def groq_generate(client, prompt: str, max_tokens: int = 2048) -> str: return r.choices[0].message.content -def is_valid_test(name: str) -> bool: - ignore = ['page', 'id', 'patient', 'date', 'sex', 'age', 'mrn', 'doctor', - 'physician', 'result', 'unit', 'range', 'validated', 'approved', - 'Interpretation', 'Ref'] - n = str(name).lower() - return not any(x in n for x in ignore) and len(str(name).strip()) > 1 - - -def get_status(value: str, range_str: str) -> str: - try: - nums = re.findall(r"[-+]?\d*\.?\d+", str(range_str)) - val = float(value) - if len(nums) >= 2: - lo, hi = float(nums[0]), float(nums[1]) - if val < lo: return "low" - if val > hi: return "high" - return "normal" - except Exception: - pass - return "normal" - def generate_search_queries(client, query: str) -> list: prompt = f"""أنت مساعد بحث طبي. حوّل السؤال إلى 3 استعلامات بحث مختلفة. @@ -251,71 +365,27 @@ def generate_search_queries(client, query: str) -> list: return [query] -def cohere_rerank(co_client, results: list, query: str, top_n: int = 5) -> list: - if not co_client or len(results) <= 1: - return results - try: - docs_text = [doc.page_content for doc, _ in results] - resp = co_client.rerank(model="rerank-v3.5", query=query, documents=docs_text, top_n=top_n) - return [(results[r.index][0], r.relevance_score) for r in resp.results] - except Exception as e: - print(f"[COHERE] {e} — BM25 fallback") - return results[:top_n] - -def bm25_rerank(results: list, query: str) -> list: - if len(results) <= 1: - return results - docs = [doc for doc, _ in results] - vscores = [s for _, s in results] - tok = [re.findall(r'\w+', d.page_content.lower()) for d in docs] - bm25 = BM25Okapi(tok) - bscores = bm25.get_scores(re.findall(r'\w+', query.lower())) - mx = max(bscores) if max(bscores) > 0 else 1 - combined = [(d, vs + (bs / mx) * 0.3) for d, vs, bs in zip(docs, vscores, bscores)] - return sorted(combined, key=lambda x: x[1], reverse=True) - -def get_rag_context(db, query: str, co_client=None, multi_query_client=None, k: int = 10, topic_type: str = None) -> tuple: +def get_rag_context( + _db_unused, + query: str, + co_client=None, + multi_query_client=None, + k: int = 10, + topic_type: str = None, +) -> tuple[str, str]: + """Thin wrapper over Retriever — kept for backward compatibility.""" try: - queries = generate_search_queries(multi_query_client, query) if multi_query_client else [query] - seen = {} - - # بحث عام - for q in queries: - for doc, score in db.similarity_search_with_relevance_scores(q, k=k): - key = doc.page_content[:80] - if key not in seen or seen[key][1] < score: - seen[key] = (doc, score) - - # بحث مُصفَّى بـ metadata إن حُدِّد نوع (يضيف نتائج إضافية أكثر صلة) - if topic_type: - try: - for q in queries[:2]: - for doc, score in db.similarity_search_with_relevance_scores( - q, k=k, filter={"topic_type": topic_type} - ): - key = doc.page_content[:80] - boosted = min(score + 0.15, 1.0) # رفع أولوية النتائج المصفاة - if key not in seen or seen[key][1] < boosted: - seen[key] = (doc, boosted) - except Exception: - pass # ChromaDB قديم لا يدعم filter، تجاهل - - filtered = [(d, s) for d, s in seen.values() if s >= 0.4] - if not filtered: - return "", "لا يوجد" - - top_scores = sorted([s for _, s in filtered], reverse=True)[:5] - avg = sum(top_scores) / len(top_scores) - conf = "عالية" if avg > 0.70 else "متوسطة" if avg > 0.45 else "منخفضة" - - top = cohere_rerank(co_client, filtered, query) if co_client else bm25_rerank(filtered, query)[:5] - context = "\n\n".join([d.page_content for d, _ in top]) - print(f"[RAG] '{query[:40]}' | n={len(filtered)} | conf={conf} ({avg:.2f}) | filter={topic_type}") - return context, conf + _, _, _, _, retriever = load_tools() + use_mq = multi_query_client is not None + results, conf = retriever.retrieve( + query, + RetrievalConfig(k=k, use_multi_query=use_mq, topic_type=topic_type), + ) + return build_context(results), conf except Exception as e: - print(f"[RAG ERROR] {e}") + log.error("RAG retrieval failed: %s", e) return "", "لا يوجد" @@ -324,14 +394,26 @@ def get_rag_context(db, query: str, co_client=None, multi_query_client=None, k: # ══════════════════════════════════════════════ def _sb_headers(): + # Use service_role key when available — bypasses RLS on analyses/chat_messages. + # Falls back to anon key (works if RLS is not yet enabled). + key = SUPABASE_SERVICE_KEY or SUPABASE_KEY return { - "apikey": SUPABASE_KEY, - "Authorization": f"Bearer {SUPABASE_KEY}", + "apikey": key, + "Authorization": f"Bearer {key}", "Content-Type": "application/json", "Prefer": "return=representation", } +def _assert_session_owner(session_id: str, user: dict | None) -> None: + """When an authenticated user is present, ensure they own the session_id.""" + if user and user.get("sub") and session_id != user["sub"]: + raise HTTPException( + status_code=403, + detail={"error": "forbidden", "message": "لا يمكنك الوصول إلى بيانات جلسة أخرى"}, + ) + + class SaveAnalysisRequest(BaseModel): session_id: str = "anonymous" findings: list @@ -340,7 +422,8 @@ class SaveAnalysisRequest(BaseModel): @app.post("/api/analyses/save") -async def save_analysis(req: SaveAnalysisRequest): +async def save_analysis(req: SaveAnalysisRequest, user: dict | None = Depends(optional_user)): + _assert_session_owner(req.session_id, user) if not SUPABASE_URL or not SUPABASE_KEY: raise HTTPException(503, "Supabase keys not configured") try: @@ -362,7 +445,9 @@ async def save_analysis(req: SaveAnalysisRequest): @app.get("/api/analyses/list") -async def list_analyses(session_id: str = "anonymous", profile_name: str = "", limit: int = 20): +async def list_analyses(session_id: str = "anonymous", profile_name: str = "", limit: int = 20, + user: dict | None = Depends(optional_user)): + _assert_session_owner(session_id, user) if not SUPABASE_URL or not SUPABASE_KEY: raise HTTPException(503, "Supabase keys not configured") try: @@ -381,9 +466,28 @@ async def list_analyses(session_id: str = "anonymous", profile_name: str = "", l raise HTTPException(500, str(e)) +@app.delete("/api/analyses/clear") +async def clear_analyses(session_id: str = "anonymous", user: dict | None = Depends(optional_user)): + _assert_session_owner(session_id, user) + if not SUPABASE_URL or not SUPABASE_KEY: + raise HTTPException(503, "Supabase keys not configured") + try: + r = http_requests.delete( + f"{SUPABASE_URL}/rest/v1/analyses", + headers=_sb_headers(), + params={"session_id": f"eq.{session_id}"}, + timeout=10, + ) + r.raise_for_status() + return {"deleted": True, "session_id": session_id} + except Exception as e: + raise HTTPException(500, str(e)) + + @app.get("/api/analyses/profiles") -async def list_profiles(session_id: str = "anonymous"): +async def list_profiles(session_id: str = "anonymous", user: dict | None = Depends(optional_user)): """جلب قائمة أفراد العائلة المسجلين لهذا المستخدم""" + _assert_session_owner(session_id, user) if not SUPABASE_URL or not SUPABASE_KEY: raise HTTPException(503, "Supabase keys not configured") try: @@ -415,127 +519,39 @@ ANALYSIS_TYPE_HINTS = { @app.post("/api/analyze") -async def analyze(file: UploadFile = File(...), analysis_type: str = Form("شامل")): - reader, embeddings, client, co_client, db = load_tools() +async def analyze(request: Request, file: UploadFile = File(...), analysis_type: str = Form("شامل"), + _rl: None = Depends(limit_analyze)): content = await file.read() - print(f"[ANALYZE] type={analysis_type} | file={file.filename}") - - # استخراج النص - if file.content_type == "application/pdf": - doc = fitz.open(stream=content, filetype="pdf") - all_text = "\n".join([p.get_text() for p in doc]) - elif GOOGLE_VISION_KEY: - try: - all_text = extract_text_google_vision(content) - except Exception as e: - print(f"[VISION] fallback to EasyOCR ({e})") - all_text = "\n".join(reader.readtext(preprocess_image(content), detail=0)) - else: - all_text = "\n".join(reader.readtext(preprocess_image(content), detail=0)) - - # استخراج الفحوصات - pattern = r"([a-zA-Z][a-zA-Z\s#%]{2,})\s+(\d+\.?\d*)\s+([\d\.]+\s*-\s*[\d\.]+)\s*([a-zA-Z0-9^/]+)?" - findings_raw = [f for f in re.findall(pattern, all_text) if is_valid_test(f[0])] - - if len(findings_raw) < 2: - try: - clean = groq_generate(client, f"""حلل النص الطبي واستخرج نتائج التحاليل. -النص: {all_text[:4000]} -أجب بقائمة بايثون فقط: [('Test Name', 'Value', 'Range', 'Unit')]""", max_tokens=1024) - findings_raw = eval(clean.strip().replace("```python", "").replace("```", "")) - except Exception: - pass - - findings = [ - {"name": str(f[0]).strip(), "value": str(f[1]), "range": str(f[2]), - "unit": str(f[3]) if len(f) > 3 else "", "status": get_status(f[1], f[2])} - for f in findings_raw if is_valid_test(f[0]) - ] + validate_upload(file, content) + analysis_type = sanitize_text(analysis_type, max_len=100) + log.info("analyze: type=%s file=%s size=%d", analysis_type, file.filename, len(content)) + + coordinator = load_coordinator() + file_type = "pdf" if file.content_type == "application/pdf" else "image" + result = coordinator.run(content, file_type, analysis_type) + + if not result.ok: + raise HTTPException(status_code=422, detail=result.error) + + response: dict = { + "findings": result.findings, + "summary": result.summary, + "report": result.report, + "panel_code": result.panel_code, + } + if os.getenv("ENVIRONMENT") == "development": + response["_agents"] = result.logs - abnormal = [f for f in findings if f["status"] != "normal"] - summary = ("القيم التي تحتاج انتباهاً: " + "، ".join([f["name"] for f in abnormal[:3]])) if abnormal else "جميع القيم ضمن المعدل الطبيعي ✓" - - type_hint = ANALYSIS_TYPE_HINTS.get(analysis_type, ANALYSIS_TYPE_HINTS["شامل"]) - search_query = f"{type_hint}: " + ", ".join([f["name"] for f in findings]) - # استخدام metadata filter للتحاليل المحددة - topic_filter = "lab_definition" if analysis_type != "شامل" else None - context, conf_label = get_rag_context(db, search_query, co_client, multi_query_client=client, topic_type=topic_filter) - - report_prompt = ( - "أنت طبيب مختبر خبير. أرجع JSON فقط بدون أي نص خارجه. ابدأ بـ { مباشرة.\n" - "لا HTML ولا markdown. نصوص عربية بسيطة فقط.\n\n" - f"النتائج: {findings}\nالمراجع: {context or 'لا توجد مراجع'}\n\n" - '{"تقييم_عام":"جملتان عن الحالة",' - '"قيم_غير_طبيعية":[{"اسم_الفحص":"","النتيجة":"","المعدل_الطبيعي":"","الحالة":"","الشرح":"","المرجع":""}],' - '"نصائح":["نصيحة1","نصيحة2","نصيحة3"]}' - ) - print(f"[ANALYZE] findings={len(findings)}, abnormal={len(abnormal)}, conf={conf_label}") - - def fallback_tips(abn): - base = ["تناول غذاءً متوازناً غنياً بالخضروات", "اشرب 8 أكواب ماء يومياً", - "مارس رياضة خفيفة 30 دقيقة يومياً", "احرص على النوم 7-8 ساعات", - "تجنب التوتر وخصص وقتاً للراحة", "راجع طبيبك لمتابعة التحاليل"] - extra = { - "glucose": ["قلل السكريات والنشويات المكررة", "مارس الرياضة بانتظام"], - "hemoglobin": ["تناول أطعمة الحديد كالسبانخ واللحوم", "فيتامين C يحسن امتصاص الحديد"], - "eosinophils": ["تجنب مسببات الحساسية", "أخبر طبيبك بأي أعراض حساسية"], - } - tips = list(base) - for f in abn: - for key, sp in extra.items(): - if key in f["name"].lower(): - tips.extend(sp); break - return tips[:8] - - report = {} - try: - raw = groq_generate(client, report_prompt) - raw = raw.strip().replace("```json", "").replace("```", "").strip() - if '{' in raw: - raw = raw[raw.index('{'):raw.rindex('}') + 1] - rd = json.loads(raw) - report = { - "general": rd.get("تقييم_عام", summary), - "abnormal_details": rd.get("قيم_غير_طبيعية", []), - "tips": rd.get("نصائح", []) or fallback_tips(abnormal), - "rag_confidence": conf_label, - } - except Exception as e: - print(f"[ERROR] JSON parse: {e}") - report = { - "general": summary, - "abnormal_details": [ - {"اسم_الفحص": f["name"], "النتيجة": f["value"], "المعدل_الطبيعي": f["range"], - "الحالة": "مرتفع" if f["status"] == "high" else "منخفض", - "الشرح": f"قيمة {'مرتفعة' if f['status'] == 'high' else 'منخفضة'} عن المعدل.", - "المرجع": "لا يوجد"} for f in abnormal - ], - "tips": fallback_tips(abnormal), - "rag_confidence": conf_label, - } - - if not report.get("abnormal_details") and abnormal: - report["abnormal_details"] = [ - {"اسم_الفحص": f["name"], "النتيجة": f["value"], "المعدل_الطبيعي": f["range"], - "الحالة": "مرتفع" if f["status"] == "high" else "منخفض", - "الشرح": f"قيمة {'مرتفعة' if f['status'] == 'high' else 'منخفضة'} عن المعدل.", - "المرجع": "لا يوجد"} for f in abnormal - ] - - return {"findings": findings, "summary": summary, "report": report} + return response # ══════════════════════════════════════════════ # M3 — Chat: ذاكرة + ربط التحليل + فلتر ذكي + مصادر # ══════════════════════════════════════════════ -CHAT_SYSTEM = """أنت مساعد طبي ذكي اسمك "تبيان". أجب بشكل واضح ومختصر باللغة العربية. -قواعد صارمة: -1. لا تخترع معلومات — إذا لم تكن متأكداً قل "لا أعلم، استشر طبيبك" -2. اذكر مصدر كل معلومة مهمة (مثال: وفقاً لـ Mayo Clinic | WHO | MedlinePlus) -3. لا تشخّص أمراضاً بشكل قاطع -4. إذا سُئلت عن أعراض، أضف "التحاليل المقترحة:" في النهاية -5. انصح دائماً بمراجعة الطبيب المختص""" +CHAT_SYSTEM = load_prompt("system_chat") or load_prompt("templates/system_prompt") or \ + """أنت مساعد طبي ذكي اسمك "تبيان". أجب بشكل واضح ومختصر باللغة العربية. +قواعد: لا تخترع معلومات. اذكر المصادر. لا تشخّص بشكل قاطع. انصح بمراجعة الطبيب دائماً.""" _FALLBACK_WORDS = ["ألم","صداع","تعب","دوخة","حمى","سعال","أعراض","ضغط","سكر","قلب", "معدة","تنفس","التهاب","دواء","علاج","تحليل","دم","فيتامين","نتائج", @@ -544,17 +560,8 @@ _FALLBACK_WORDS = ["ألم","صداع","تعب","دوخة","حمى","سعال"," def is_medical_query(client, query: str) -> bool: - """M3 — فلتر ذكي بـ Groq بدل قائمة الكلمات""" - try: - r = client.chat.completions.create( - model=GROQ_MODEL_CHAT, - messages=[{"role": "user", - "content": f"هل هذا السؤال يتعلق بالصحة أو الطب أو التحاليل أو الأدوية؟ أجب بـ نعم أو لا فقط:\n{query}"}], - temperature=0, max_tokens=5, - ) - return "نعم" in r.choices[0].message.content - except Exception: - return any(w in query for w in _FALLBACK_WORDS) + """فلتر سريع بالكلمات المفتاحية — بدون Groq call""" + return any(w in query for w in _FALLBACK_WORDS) or len(query.split()) >= 3 class ChatRequest(BaseModel): @@ -564,25 +571,31 @@ class ChatRequest(BaseModel): @app.post("/api/chat") -async def chat_stream(req: ChatRequest): - client = load_groq() +async def chat_stream(request: Request, req: ChatRequest, _rl: None = Depends(limit_chat)): + req.query = sanitize_query(sanitize_text(req.query)) + router = load_router() - if not is_medical_query(client, req.query): + if not is_medical_query(None, req.query): def nm(): yield "أنا مساعد طبي متخصص. يسعدني الإجابة على أسئلتك الصحية وتحاليلك الطبية." return StreamingResponse(nm(), media_type="text/plain; charset=utf-8") - system = CHAT_SYSTEM - - # RAG: استرجاع معلومات طبية من قاعدة المعرفة - try: - _, _, _, co_client, db = load_tools() - rag_ctx, _ = get_rag_context(db, req.query, co_client, multi_query_client=client, k=6) - if rag_ctx: - system += f"\n\nمعلومات طبية من قاعدة المعرفة (استند إليها):\n{rag_ctx}" - except Exception: - pass + # ── RAG context (lightweight — cached to avoid repeated embedding calls) ── + rag_ctx = "" + _rag_key = rag_cache_key(req.query) + rag_ctx = rag_cache.get(_rag_key) or "" + if not rag_ctx: + try: + _, _, _, _, retriever = load_tools() + fast_results = retriever.retrieve_fast(req.query, k=5, top_n=3) + rag_ctx = build_context(fast_results, max_tokens=500) + if rag_ctx: + rag_cache.set(_rag_key, rag_ctx, ttl=300) + except Exception: + pass + # ── بناء analysis context ── + analysis_ctx = "" if req.analysis_context: try: ctx = json.loads(req.analysis_context) @@ -590,22 +603,26 @@ async def chat_stream(req: ChatRequest): summary = ctx.get("summary", "") abnormal = [f for f in findings if f.get("status") != "normal"] normal = [f for f in findings if f.get("status") == "normal"] - - ctx_lines = [f"ملخص التحليل: {summary}"] + lines = [f"ملخص التحليل: {summary}"] if abnormal: - ctx_lines.append("النتائج غير الطبيعية:") + lines.append("النتائج غير الطبيعية:") for f in abnormal: direction = "مرتفع" if f.get("status") == "high" else "منخفض" - ctx_lines.append( + lines.append( f" • {f['name']}: {f['value']} {f.get('unit','')} " f"(المعدل: {f.get('range','')}) — {direction}" ) if normal: - ctx_lines.append(f"النتائج الطبيعية: {', '.join(f['name'] for f in normal)}") - - system += "\n\nبيانات تحليل المريض (استند إليها مباشرة عند الإجابة):\n" + "\n".join(ctx_lines) + lines.append(f"النتائج الطبيعية: {', '.join(f['name'] for f in normal)}") + analysis_ctx = "\n".join(lines) except Exception: - system += f"\n\nنتائج التحليل:\n{req.analysis_context}" + analysis_ctx = req.analysis_context + + system = ( + CHAT_SYSTEM + .replace("{{RAG_CONTEXT}}", rag_ctx or "لا توجد معلومات إضافية.") + .replace("{{ANALYSIS_CONTEXT}}", analysis_ctx or "لم يُرفع تحليل بعد.") + ) messages = [{"role": "system", "content": system}] for msg in req.history[-10:]: # آخر 10 رسائل فقط @@ -613,27 +630,102 @@ async def chat_stream(req: ChatRequest): messages.append({"role": msg["role"], "content": msg["content"]}) messages.append({"role": "user", "content": req.query}) + # Emergency check — يُرد فوراً بدون LLM + emergency_resp = check_emergency(req.query) + if emergency_resp: + def em(): + yield emergency_resp + return StreamingResponse(em(), media_type="text/plain; charset=utf-8") + def generate(): + tokens: list[str] = [] try: - stream = client.chat.completions.create( - model=GROQ_MODEL_CHAT, - messages=messages, - temperature=0.1, # M3 — أقل هلوسة - max_tokens=800, - stream=True, - ) - for chunk in stream: - token = chunk.choices[0].delta.content or "" - if token: - yield token + for token in router.stream(messages, max_tokens=600, temperature=0.1, + model_hint="chat"): + tokens.append(token) + yield token except Exception as e: err = str(e) - print(f"[CHAT ERROR] {err[:200]}") + log.error("chat stream error: %s", err[:200]) yield "عذراً، الخدمة مشغولة. حاول بعد لحظة." if "429" in err else "حدث خطأ، يرجى المحاولة مرة أخرى." + return + full = "".join(tokens) + suffix = filter_chat_response(full, req.query) + if suffix != full: + delta = suffix[len(full):] + if delta: + yield delta return StreamingResponse(generate(), media_type="text/plain; charset=utf-8") +class SaveChatRequest(BaseModel): + session_id: str + messages: list[dict] # [{"role": "user"|"assistant", "content": str}] + + +@app.get("/api/chat/history/{session_id}") +async def get_chat_history(session_id: str, limit: int = 30, + user: dict | None = Depends(optional_user)): + """تحميل آخر N رسالة لجلسة معينة من Supabase.""" + _assert_session_owner(session_id, user) + if not SUPABASE_URL or not SUPABASE_KEY: + return {"messages": []} + headers = { + "apikey": SUPABASE_KEY, + "Authorization": f"Bearer {SUPABASE_KEY}", + } + try: + r = http_requests.get( + f"{SUPABASE_URL}/rest/v1/chat_messages", + headers=headers, + params={ + "session_id": f"eq.{session_id}", + "order": "created_at.asc", + "limit": limit, + "select": "role,content", + }, + timeout=10, + ) + r.raise_for_status() + return {"messages": r.json()} + except Exception as e: + log.warning("chat/history fetch failed: %s", e) + return {"messages": []} + + +@app.post("/api/chat/save") +async def save_chat_messages(req: SaveChatRequest, user: dict | None = Depends(optional_user)): + """حفظ رسائل تبادل واحد (مستخدم + مساعد) في Supabase.""" + _assert_session_owner(req.session_id, user) + if not SUPABASE_URL or not SUPABASE_KEY: + return {"ok": False} + headers = { + "apikey": SUPABASE_KEY, + "Authorization": f"Bearer {SUPABASE_KEY}", + "Content-Type": "application/json", + "Prefer": "return=minimal", + } + rows = [ + {"session_id": req.session_id, "role": m["role"], "content": m["content"]} + for m in req.messages + if m.get("content", "").strip() + ] + if not rows: + return {"ok": True} + try: + r = http_requests.post( + f"{SUPABASE_URL}/rest/v1/chat_messages", + headers=headers, + json=rows, + timeout=10, + ) + return {"ok": r.status_code in (200, 201)} + except Exception as e: + log.warning("chat/save failed: %s", e) + return {"ok": False} + + class EvalRequest(BaseModel): question: str reference_answer: str = "" @@ -690,6 +782,371 @@ async def evaluate_rag(req: EvalRequest): } +class SearchAnalysisItem(BaseModel): + id: str + summary: str + panel: str = "" + findings_text: str = "" + + +class SemanticSearchRequest(BaseModel): + query: str + analyses: list[SearchAnalysisItem] = [] + search_scope: str = "local" # "local" = user history | "global" = medical KB (pgvector) + top_k: int = 5 + + +@app.post("/api/search") +async def semantic_search(request: Request, req: SemanticSearchRequest, _rl: None = Depends(limit_search)): + """ + Semantic search with two modes: + local — re-rank the user's own saved analyses (default, original behaviour) + global — query the medical knowledge base in pgvector directly + """ + from services.search.query_parser import parse_query, normalize_arabic + + if not req.query.strip(): + if req.search_scope == "local": + return {"results": [{"id": a.id, "score": 0.0} for a in req.analyses], "scope": "local"} + return {"results": [], "scope": "global"} + + # ── Global KB search ─────────────────────────────────────────────────── + if req.search_scope == "global": + clean_query = sanitize_query(req.query) + _s_key = rag_cache_key(clean_query, topic_type="search_global") + cached = search_cache.get(_s_key) + if cached: + return cached + try: + _, _, _, _, retriever = load_tools() + kb_results, confidence = retriever.retrieve( + clean_query, + RetrievalConfig(k=req.top_k * 2, top_n=req.top_k, use_multi_query=False), + ) + payload = { + "results": [ + { + "id": r.source, + "score": round(r.score, 3), + "content": r.content[:300], + "source": r.source, + "topic_type": r.metadata.get("topic_type", ""), + } + for r in kb_results + ], + "scope": "global", + "confidence": confidence, + } + search_cache.set(_s_key, payload, ttl=120) + return payload + except Exception as e: + log.error("global search failed: %s", e) + raise HTTPException(500, detail=f"Global search failed: {e}") + + # ── Local history search (original behaviour) ────────────────────────── + pq = parse_query(req.query) + norm_q = pq.normalized.lower() + + all_terms: list[str] = [norm_q] + for exp in pq.search_expansions[1:]: + for t in exp.split(): + n = normalize_arabic(t.lower()) + if len(n) > 2: + all_terms.append(n) + for t in pq.detected_tests: + all_terms.append(normalize_arabic(t.lower())) + all_terms = list(dict.fromkeys(all_terms)) + + results = [] + for a in req.analyses: + text = normalize_arabic((a.summary + " " + a.panel + " " + a.findings_text).lower()) + score = 0.0 + for term in all_terms: + if len(term) > 1 and term in text: + score += 2.0 if term in norm_q else 1.0 + if pq.panel_type and a.panel and pq.panel_type == a.panel: + score += 3.0 + if norm_q in text: + score += 2.0 + results.append({"id": a.id, "score": round(score, 1)}) + + results.sort(key=lambda x: x["score"], reverse=True) + return {"results": results, "scope": "local"} + + @app.get("/api/health") -def health(): - return {"status": "ok", "embed_model": EMBED_MODEL} +def api_health(): + return { + "status": "ok", + "embed_model": EMBED_MODEL, + "voice": { + "stt": bool(GROQ_API_KEY), + "tts": bool(GOOGLE_TTS_KEY or ELEVENLABS_KEY or True), + }, + } + + +@app.get("/api/models/status") +def models_status(): + """Reports the active LLM provider, loaded adapters, and fallback state.""" + router = load_router() + tts_provider = "none" + if GOOGLE_TTS_KEY: + tts_provider = "google_cloud" + elif ELEVENLABS_KEY: + tts_provider = "elevenlabs" + else: + try: + import gtts # noqa: F401 + tts_provider = "gtts_free" + except ImportError: + pass + + return { + "llm": { + "provider": router.provider_name, + "lora_loaded": _lora_model_info["loaded"], + "lora_path": _lora_model_info.get("path"), + "lora_model": _lora_model_info.get("model_name"), + "env_LLM_PROVIDER": os.getenv("LLM_PROVIDER", "groq"), + }, + "embeddings": { + "model": EMBED_MODEL, + "loaded": True, + }, + "tts_provider": tts_provider, + "stt_provider": "groq_whisper" if GROQ_API_KEY else "none", + "cache": { + "rag": rag_cache.stats(), + }, + } + + +# ══════════════════════════════════════════════ +# Voice AI endpoints +# ══════════════════════════════════════════════ + +@lru_cache(maxsize=1) +def _get_stt(): + from services.voice import WhisperSTT + return WhisperSTT(GROQ_API_KEY) + +@lru_cache(maxsize=1) +def _get_tts(): + from services.voice import get_tts_provider + return get_tts_provider(google_tts_key=GOOGLE_TTS_KEY, elevenlabs_key=ELEVENLABS_KEY) + + +@app.post("/api/voice/transcribe") +async def voice_transcribe( + audio: UploadFile = File(...), + language: str = Form("ar"), +): + """ + Transcribe Arabic audio (webm/mp4/wav/ogg) → text. + Uses Groq Whisper large-v3. + """ + data = await audio.read() + mime_type = audio.content_type or "audio/webm" + try: + stt = _get_stt() + text = stt.transcribe(data, mime_type=mime_type, language=language) + return {"text": text, "language": language} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Transcription failed: {e}") + + +@app.post("/api/voice/synthesize") +async def voice_synthesize(req: dict): + """ + Convert Arabic text → MP3 audio bytes. + Body: {"text": "..."} + """ + text = (req.get("text") or "").strip() + if not text: + raise HTTPException(status_code=422, detail="text field is required") + try: + tts = _get_tts() + mp3_data = tts.synthesize(text) + return HttpResponse(content=mp3_data, media_type="audio/mpeg") + except Exception as e: + raise HTTPException(status_code=500, detail=f"TTS failed: {e}") + + +class VoiceChatRequest(BaseModel): + audio_base64: str = "" # base64-encoded audio (alternative to file upload) + query: str = "" # text query (if already transcribed client-side) + history: list[dict] = [] + analysis_context: str = "" + language: str = "ar" + include_audio: bool = True # return TTS audio in response + + +@app.post("/api/voice/chat") +async def voice_chat(req: VoiceChatRequest): + """ + Full voice chat loop: audio → STT → LLM → TTS. + Returns {text: str, audio_base64: str (MP3)}. + """ + import base64 + + # 1. Resolve input text (from audio or direct text) + query = req.query.strip() + if not query and req.audio_base64: + try: + audio_bytes = base64.b64decode(req.audio_base64) + stt = _get_stt() + query = stt.transcribe(audio_bytes, language=req.language) + except Exception as e: + raise HTTPException(status_code=500, detail=f"STT failed: {e}") + + if not query: + raise HTTPException(status_code=422, detail="Provide audio_base64 or query") + + query = sanitize_query(query) + + # 2. LLM chat + router = load_router() + emergency_resp = check_emergency(query) + if emergency_resp: + text = emergency_resp + else: + rag_ctx = "" + _rag_key = rag_cache_key(query) + rag_ctx = rag_cache.get(_rag_key) or "" + if not rag_ctx: + try: + _, _, _, _, retriever = load_tools() + fast_results = retriever.retrieve_fast(query, k=5, top_n=3) + rag_ctx = build_context(fast_results, max_tokens=400) + if rag_ctx: + rag_cache.set(_rag_key, rag_ctx, ttl=300) + except Exception: + pass + + system = ( + CHAT_SYSTEM + .replace("{{RAG_CONTEXT}}", rag_ctx or "لا توجد معلومات إضافية.") + .replace("{{ANALYSIS_CONTEXT}}", req.analysis_context or "لم يُرفع تحليل بعد.") + ) + messages = [{"role": "system", "content": system}] + for msg in req.history[-6:]: + if msg.get("role") in ("user", "assistant") and msg.get("content"): + messages.append({"role": msg["role"], "content": msg["content"]}) + messages.append({"role": "user", "content": query}) + + raw = router.generate(messages, max_tokens=600, temperature=0.5, model_hint="chat") + text = filter_chat_response(raw, query) + + # 3. TTS (optional) + audio_b64 = "" + if req.include_audio: + try: + tts = _get_tts() + mp3 = tts.synthesize(text) + audio_b64 = base64.b64encode(mp3).decode() + except Exception as e: + log = logging.getLogger("tebyan") + log.warning("[voice/chat] TTS failed: %s", e) + + return {"query": query, "text": text, "audio_base64": audio_b64} + + +# ══════════════════════════════════════════════ +# Risk Prediction API +# ══════════════════════════════════════════════ + +class RiskRequest(BaseModel): + findings: list[dict] # same format as analysis findings + +@lru_cache(maxsize=1) +def _get_risk_engine(): + from services.risk import RiskEngine + return RiskEngine() + +@app.post("/api/risk") +async def predict_risk(req: RiskRequest): + """ + Evidence-based multi-condition health risk scoring. + Returns scored risks for diabetes, cardiovascular, anemia, kidney, liver, thyroid. + ML model override when models/ directory contains trained .pkl files. + """ + if not req.findings: + raise HTTPException(status_code=422, detail="findings list is required") + engine = _get_risk_engine() + report = engine.assess(req.findings) + return { + "risks": [ + { + "condition": r.condition, + "score": r.score, + "level": r.level, + "confidence": r.confidence, + "label_ar": r.label_ar, + "factors": r.factors, + "recommendation": r.recommendation, + "source": r.source, + } + for r in report.risks + ], + "top_risk": { + "condition": report.top_risk.condition, + "score": report.top_risk.score, + "level": report.top_risk.level, + "label_ar": report.top_risk.label_ar, + } if report.top_risk else None, + "overall_ar": report.overall_ar, + "features_used": report.features_used, + } + + +# ══════════════════════════════════════════════ +# Compare Summary — Groq-generated analysis of two lab results +# ══════════════════════════════════════════════ + +class CompareSummaryRequest(BaseModel): + findings_a: list[dict] + findings_b: list[dict] + summary_a: str = "" + summary_b: str = "" + date_a: str = "" + date_b: str = "" + +@app.post("/api/compare/summary") +async def compare_summary(req: CompareSummaryRequest): + router = load_router() + + def fmt_findings(findings: list[dict]) -> str: + lines = [] + for f in findings: + status_ar = {"high": "مرتفع", "low": "منخفض", "normal": "طبيعي"}.get(f.get("status", ""), "") + lines.append(f" {f.get('name','')}: {f.get('value','')} {f.get('unit','')} [{status_ar}]") + return "\n".join(lines) if lines else "لا توجد بيانات" + + prompt = f"""أنت طبيب متخصص. قارن بين نتيجتين لتحاليل مخبرية لنفس المريض. + +التحليل الأول ({req.date_a or 'السابق'}): +{fmt_findings(req.findings_a)} + +التحليل الثاني ({req.date_b or 'الحالي'}): +{fmt_findings(req.findings_b)} + +اكتب ملخصاً طبياً دقيقاً بالعربية في 3-4 جمل يشمل: +1. أبرز التغيرات (تحسّن / تراجع / ثبات) +2. هل الاتجاه العام إيجابي أم يستدعي قلقاً +3. توصية واحدة محددة + +أجب مباشرة بالنص فقط، بدون عناوين أو نقاط.""" + + try: + summary = router.generate( + [{"role": "user", "content": prompt}], + max_tokens=300, temperature=0.2, model_hint="analysis", + ).strip() + except Exception as e: + summary = "تعذّر توليد الملخص المقارن." + log.error("compare: Groq error: %s", e) + + return {"summary": summary} + +# build:1779660596 \ No newline at end of file diff --git a/backend/medical_data.py b/backend/medical_data.py new file mode 100644 index 0000000000000000000000000000000000000000..a25f546f62d0421fb01ea02e8183f26dbdc6b5d5 --- /dev/null +++ b/backend/medical_data.py @@ -0,0 +1,727 @@ +""" +بيانات طبية مركزية — تستخدمها سكريبتات الاستيراد والـ API +""" + +LAB_DEFINITIONS = [ + { + "name_ar": "هيموجلوبين", "name_en": "Hemoglobin (HGB)", + "definition": "بروتين في خلايا الدم الحمراء يحمل الأكسجين. القيم الطبيعية: رجال 13.5-17.5 g/dL، نساء 12-15.5 g/dL.", + "high": "كثرة الحمر، الجفاف، أمراض الرئة المزمنة.", + "low": "فقر الدم، نزيف داخلي، نقص الحديد أو فيتامين B12 أو حمض الفوليك.", + "symptoms_low": "تعب، شحوب، ضيق تنفس، دوخة، خفقان قلب.", + }, + { + "name_ar": "خلايا الدم الحمراء", "name_en": "Red Blood Cells (RBC)", + "definition": "عدد خلايا الدم الحمراء. القيم الطبيعية: رجال 4.5-5.9 مليون/µL، نساء 4.1-5.1 مليون/µL.", + "high": "كثرة الحمر، الجفاف، أمراض القلب الخلقية.", + "low": "فقر الدم، نزيف، فشل كلوي، نقص غذائي.", + "symptoms_low": "إرهاق، شحوب، ضعف عام.", + }, + { + "name_ar": "خلايا الدم البيضاء", "name_en": "White Blood Cells (WBC)", + "definition": "خلايا الجهاز المناعي. القيم الطبيعية: 4,000-11,000 خلية/µL.", + "high": "عدوى بكتيرية، التهاب، أمراض الدم كاللوكيميا.", + "low": "أمراض المناعة، العلاج الكيميائي، أمراض النخاع العظمي.", + "symptoms_low": "تكرار الإصابة بالعدوى، حمى متكررة.", + }, + { + "name_ar": "الصفائح الدموية", "name_en": "Platelets (PLT)", + "definition": "خلايا مسؤولة عن تخثر الدم. القيم الطبيعية: 150,000-400,000/µL.", + "high": "التهاب، نزيف، نقص الحديد، خطر تجلط الدم.", + "low": "نزيف تلقائي، أمراض الكبد، الذئبة، العلاج الكيميائي.", + "symptoms_low": "كدمات سهلة، نزيف اللثة، نزيف طويل عند الجرح.", + }, + { + "name_ar": "سكر الدم الصائم", "name_en": "Fasting Blood Glucose", + "definition": "مستوى السكر بعد 8 ساعات صيام. طبيعي: 70-100 mg/dL. ما قبل السكري: 100-125. سكري: 126 فأكثر.", + "high": "السكري، مقاومة الأنسولين، الإجهاد، بعض الأدوية.", + "low": "نقص سكر الدم، الصيام الطويل، جرعة زائدة من الأنسولين.", + "symptoms_low": "رعشة، تعرق، دوخة، فقدان وعي في الحالات الشديدة.", + }, + { + "name_ar": "الهيموجلوبين الغليكوزيلاتي", "name_en": "HbA1c (Glycated Hemoglobin)", + "definition": "يقيس متوسط سكر الدم خلال 3 أشهر. طبيعي: أقل من 5.7%. ما قبل السكري: 5.7-6.4%. سكري: 6.5% فأكثر.", + "high": "سوء ضبط السكري، خطر مضاعفات السكري.", + "low": "ضبط جيد للسكري، أو فقر الدم الانحلالي (يعطي قراءة خاطئة).", + "symptoms_low": "نادراً ما يسبب أعراضاً بحد ذاته.", + }, + { + "name_ar": "الكوليسترول الكلي", "name_en": "Total Cholesterol", + "definition": "إجمالي الدهون في الدم. المثالي: أقل من 200 mg/dL. حدي: 200-239. مرتفع: 240 فأكثر.", + "high": "خطر أمراض القلب والشرايين، السكتة الدماغية.", + "low": "نادر، قد يرتبط بسوء التغذية أو مشاكل الكبد.", + "symptoms_low": "الكوليسترول المرتفع لا يسبب أعراضاً واضحة غالباً.", + }, + { + "name_ar": "الكوليسترول الضار", "name_en": "LDL Cholesterol", + "definition": "الكوليسترول منخفض الكثافة، يترسب في الشرايين. المثالي: أقل من 100 mg/dL. مرتفع: 160 فأكثر.", + "high": "تصلب الشرايين، أمراض القلب، جلطات.", + "low": "مثالي، يقلل خطر أمراض القلب.", + "symptoms_low": "لا أعراض مباشرة.", + }, + { + "name_ar": "الكوليسترول النافع", "name_en": "HDL Cholesterol", + "definition": "الكوليسترول عالي الكثافة، يزيل الكوليسترول من الشرايين. المثالي: فوق 60 mg/dL. منخفض: أقل من 40 للرجال، 50 للنساء.", + "high": "حماية من أمراض القلب.", + "low": "خطر أمراض القلب، قلة الرياضة، التدخين، السمنة.", + "symptoms_low": "لا أعراض مباشرة لكن خطر قلبي مرتفع.", + }, + { + "name_ar": "الدهون الثلاثية", "name_en": "Triglycerides", + "definition": "نوع من الدهون في الدم. طبيعي: أقل من 150 mg/dL. حدي: 150-199. مرتفع: 200-499. مرتفع جداً: 500+.", + "high": "السمنة، السكري، الكحول، قصور الغدة الدرقية.", + "low": "نادر، طبيعي أو نقص غذائي.", + "symptoms_low": "الارتفاع الشديد يسبب التهاب البنكرياس.", + }, + { + "name_ar": "هرمون الغدة الدرقية", "name_en": "TSH (Thyroid Stimulating Hormone)", + "definition": "يتحكم في نشاط الغدة الدرقية. القيم الطبيعية: 0.4-4.0 mIU/L.", + "high": "قصور الغدة الدرقية (الغدة خاملة).", + "low": "فرط نشاط الغدة الدرقية.", + "symptoms_low": "فرط النشاط: خفقان، فقدان وزن، تعرق، قلق. القصور: تعب، زيادة وزن، برد دائم.", + }, + { + "name_ar": "فيتامين د", "name_en": "Vitamin D (25-OH)", + "definition": "فيتامين ضروري لصحة العظام والمناعة. طبيعي: 30-100 ng/mL. نقص: 20-29. نقص شديد: أقل من 20.", + "high": "جرعة زائدة من المكملات (نادر من الشمس).", + "low": "قلة التعرض للشمس، سوء الامتصاص، السمنة.", + "symptoms_low": "آلام العظام، ضعف العضلات، تكرار الكسور، ضعف المناعة، اكتئاب.", + }, + { + "name_ar": "فيتامين ب12", "name_en": "Vitamin B12 (Cobalamin)", + "definition": "ضروري لصحة الأعصاب وتكوين الدم. طبيعي: 200-900 pg/mL. نقص: أقل من 200.", + "high": "نادر، قد يكون من مكملات زائدة.", + "low": "النباتيون، كبار السن، أمراض المعدة، الأدوية كالميتفورمين.", + "symptoms_low": "تنميل، وخز، فقر الدم، ضعف الذاكرة، إجهاد.", + }, + { + "name_ar": "الحديد في الدم", "name_en": "Serum Iron", + "definition": "مستوى الحديد في الدم. القيم الطبيعية: رجال 65-176 µg/dL، نساء 50-170 µg/dL.", + "high": "داء ترسب الأصبغة الدموية، نقل دم متعدد.", + "low": "نقص الحديد من سوء التغذية أو النزيف.", + "symptoms_low": "تعب، شحوب، هشاشة أظافر، تساقط شعر، صعوبة التركيز.", + }, + { + "name_ar": "الفيريتين", "name_en": "Ferritin", + "definition": "بروتين يخزن الحديد. أفضل مؤشر لمخزون الحديد. طبيعي: رجال 12-300 ng/mL، نساء 12-150 ng/mL.", + "high": "التهاب، أمراض الكبد، داء ترسب الأصبغة.", + "low": "نضوب مخزون الحديد (أول علامة لنقص الحديد قبل فقر الدم).", + "symptoms_low": "تعب مزمن، تساقط شعر، برودة الأطراف.", + }, + { + "name_ar": "حمض اليوريك", "name_en": "Uric Acid", + "definition": "ناتج تكسير البيورينات. طبيعي: رجال 3.5-7.2 mg/dL، نساء 2.6-6.0 mg/dL.", + "high": "النقرس، الفشل الكلوي، أكل كثير من اللحوم، السمنة.", + "low": "نادر، قد يكون من بعض الأدوية.", + "symptoms_low": "الارتفاع يسبب: ألم حاد في المفاصل (خاصة إصبع القدم)، حصوات كلى.", + }, + { + "name_ar": "الكرياتينين", "name_en": "Creatinine", + "definition": "مؤشر لوظائف الكلى. طبيعي: رجال 0.74-1.35 mg/dL، نساء 0.59-1.04 mg/dL.", + "high": "ضعف وظائف الكلى، الجفاف، زيادة كتلة العضلات.", + "low": "ضمور العضلات، سوء التغذية.", + "symptoms_low": "ارتفاع الكرياتينين: تورم، تعب، غثيان، قلة التبول.", + }, + { + "name_ar": "إنزيمات الكبد", "name_en": "Liver Enzymes (ALT/AST)", + "definition": "مؤشرات لصحة الكبد. ALT طبيعي: 7-56 U/L. AST طبيعي: 10-40 U/L.", + "high": "التهاب الكبد، الكبد الدهني، الكحول، بعض الأدوية.", + "low": "لا أهمية سريرية.", + "symptoms_low": "ارتفاع الإنزيمات: يرقان، تعب، ألم أعلى البطن، غثيان.", + }, + { + "name_ar": "بروتين سي التفاعلي", "name_en": "C-Reactive Protein (CRP)", + "definition": "مؤشر للالتهاب في الجسم. طبيعي: أقل من 10 mg/L. مرتفع high-sensitivity CRP: فوق 3 mg/L خطر قلبي.", + "high": "عدوى، التهاب مفاصل، أمراض مناعية، خطر قلبي.", + "low": "غياب التهاب (جيد).", + "symptoms_low": "الارتفاع يرافقه أعراض المرض الأصلي.", + }, + { + "name_ar": "الحمضات", "name_en": "Eosinophils", + "definition": "نوع من خلايا الدم البيضاء. طبيعي: 1-4% من WBC أو 100-400 خلية/µL.", + "high": "الحساسية، الربو، الطفيليات، بعض الأمراض المناعية.", + "low": "ليس ذا أهمية سريرية.", + "symptoms_low": "الارتفاع يسبب: حساسية مستمرة، طفح جلدي، أعراض ربو.", + }, + { + "name_ar": "معدل ترسيب كريات الدم الحمراء", "name_en": "ESR (Erythrocyte Sedimentation Rate)", + "definition": "مؤشر التهاب غير محدد. طبيعي: رجال أقل من 15 mm/hr، نساء أقل من 20 mm/hr.", + "high": "التهابات، أمراض مناعية، سرطان، فقر الدم.", + "low": "فشل قلبي احتقاني، كثرة الحمر.", + "symptoms_low": "لا أعراض مباشرة، يُفسر مع الفحوصات الأخرى.", + }, + { + "name_ar": "الإنزيم القلبي تروبونين", "name_en": "Troponin (Cardiac)", + "definition": "مؤشر تلف عضلة القلب. طبيعي: أقل من 0.04 ng/mL (يختلف حسب المختبر).", + "high": "نوبة قلبية، التهاب عضلة القلب، الجلطة الرئوية.", + "low": "طبيعي.", + "symptoms_low": "الارتفاع يرافق: ألم صدر، ضيق تنفس، تعرق.", + }, + { + "name_ar": "هرمون الكورتيزول", "name_en": "Cortisol", + "definition": "هرمون التوتر من الغدة الكظرية. طبيعي صباحاً: 6-23 µg/dL.", + "high": "متلازمة كوشينغ، توتر شديد، ورم الغدة.", + "low": "قصور الغدة الكظرية (مرض أديسون).", + "symptoms_low": "الارتفاع: سمنة بطنية، ضغط مرتفع، سكر. الانخفاض: إجهاد، غثيان، انخفاض ضغط.", + }, + { + "name_ar": "هرمون الإنسولين الصائم", "name_en": "Insulin (Fasting)", + "definition": "هرمون يتحكم في السكر. طبيعي صائم: 2-25 µIU/mL.", + "high": "مقاومة الأنسولين، السمنة، ما قبل السكري، ورم الأنسولين.", + "low": "السكري النوع الأول، البنكرياس الضعيف.", + "symptoms_low": "ارتفاع الأنسولين مع سكر طبيعي = مقاومة الأنسولين.", + }, + { + "name_ar": "هرمون التستوستيرون", "name_en": "Testosterone (Total)", + "definition": "الهرمون الذكري الرئيسي. طبيعي للرجال: 300-1000 ng/dL، نساء: 15-70 ng/dL.", + "high": "أورام الغدة الكظرية، استخدام الستيرويدات.", + "low": "قصور الغدد التناسلية، السمنة، الشيخوخة، التوتر الشديد.", + "symptoms_low": "الانخفاض عند الرجال: ضعف جنسي، تعب، فقدان كتلة عضلية، اكتئاب.", + }, + { + "name_ar": "هرمون البرولاكتين", "name_en": "Prolactin", + "definition": "هرمون الحليب من الغدة النخامية. طبيعي: رجال 2-18 ng/mL، نساء غير حامل 2-29 ng/mL.", + "high": "ورم النخامية، بعض الأدوية، قصور الغدة الدرقية.", + "low": "نادر، قد يكون من قصور النخامية.", + "symptoms_low": "الارتفاع: إفراز حليب، اضطراب دورة، ضعف جنسي.", + }, + { + "name_ar": "هرمون الإستروجين", "name_en": "Estradiol (E2)", + "definition": "الهرمون الأنثوي الرئيسي. يتغير حسب مرحلة الدورة والحمل.", + "high": "أورام المبيض، السمنة، أمراض الكبد.", + "low": "انقطاع الطمث، قصور المبيض، سوء التغذية.", + "symptoms_low": "الانخفاض: جفاف مهبلي، هشاشة عظام، اضطراب مزاج، هبات حرارة.", + }, + { + "name_ar": "زمن البروثرومبين", "name_en": "PT/INR (Prothrombin Time)", + "definition": "يقيس سرعة تخثر الدم. INR طبيعي: 0.8-1.2. مرضى الوارفارين: 2-3.", + "high": "نزيف، أمراض الكبد، نقص فيتامين K.", + "low": "خطر تجلط.", + "symptoms_low": "الارتفاع: نزيف سهل، كدمات، نزيف طويل.", + }, + { + "name_ar": "الأميليز والليباز", "name_en": "Amylase & Lipase", + "definition": "إنزيمات البنكرياس. أميليز طبيعي: 30-110 U/L. ليباز طبيعي: 0-160 U/L.", + "high": "التهاب البنكرياس الحاد، حصى المرارة، الكحول.", + "low": "لا أهمية سريرية.", + "symptoms_low": "الارتفاع الشديد: ألم بطن حاد، غثيان، قيء.", + }, + { + "name_ar": "تحليل البول الكامل", "name_en": "Urinalysis (Complete)", + "definition": "فحص البول للكشف عن أمراض الكلى والمسالك البولية والسكري.", + "high": "بروتين: مشكلة كلى. سكر: سكري. دم: التهاب أو حصوات.", + "low": "بول طبيعي: شفاف، أصفر فاتح، بدون بروتين أو سكر أو دم.", + "symptoms_low": "البول الغائم أو الداكن أو ذو رائحة شديدة يستوجب فحصاً.", + }, + { + "name_ar": "معدل الترشيح الكبيبي", "name_en": "eGFR (Glomerular Filtration Rate)", + "definition": "أفضل مقياس لوظيفة الكلى. طبيعي: فوق 90 mL/min. مرحلة الفشل: أقل من 15.", + "high": "غير ذي أهمية عند الارتفاع.", + "low": "مرض كلوي مزمن بدرجات متفاوتة.", + "symptoms_low": "GFR 30-59: مرحلة متوسطة. GFR 15-29: متقدمة. أقل من 15: فشل كلوي.", + }, + { + "name_ar": "حمض الفوليك", "name_en": "Folic Acid (Folate)", + "definition": "فيتامين B9 ضروري لتكوين الدم وتطور الجنين. طبيعي: 2-20 ng/mL.", + "high": "من المكملات (لا ضرر).", + "low": "سوء التغذية، الحمل، الكحول، أمراض الأمعاء.", + "symptoms_low": "فقر الدم الضخم الكريات، تشوهات الجنين (أنبوب عصبي)، تعب.", + }, + { + "name_ar": "الزنك", "name_en": "Zinc", + "definition": "معدن ضروري للمناعة والجرح والتكاثر. طبيعي: 60-120 µg/dL.", + "high": "تسمم نادر من المكملات الزائدة.", + "low": "سوء التغذية، أمراض الأمعاء، السكري.", + "symptoms_low": "ضعف المناعة، بطء التئام الجروح، فقدان الشم، تساقط الشعر.", + }, + { + "name_ar": "المغنيسيوم", "name_en": "Magnesium", + "definition": "معدن حيوي لأكثر من 300 وظيفة في الجسم. طبيعي: 1.7-2.2 mg/dL.", + "high": "الفشل الكلوي، جرعة زائدة من المكملات.", + "low": "سوء التغذية، السكري، الكحول، بعض الأدوية.", + "symptoms_low": "تشنجات عضلية، رعشة، صداع، قلق، عدم انتظام القلب.", + }, + { + "name_ar": "الألبومين", "name_en": "Albumin", + "definition": "البروتين الرئيسي في الدم، يعكس التغذية ووظيفة الكبد. طبيعي: 3.5-5.0 g/dL.", + "high": "نادر، الجفاف.", + "low": "سوء التغذية، أمراض الكبد، الكلى، الالتهابات المزمنة.", + "symptoms_low": "تورم (وذمة)، ضعف عام، بطء التئام الجروح.", + }, + { + "name_ar": "البيليروبين", "name_en": "Bilirubin (Total/Direct)", + "definition": "ناتج تكسير خلايا الدم الحمراء. طبيعي الكلي: 0.2-1.2 mg/dL.", + "high": "يرقان، أمراض الكبد، انسداد القنوات الصفراوية، فقر الدم الانحلالي.", + "low": "لا أهمية سريرية.", + "symptoms_low": "الارتفاع يسبب: اصفرار الجلد والعينين، بول داكن، براز فاتح.", + }, + { + "name_ar": "الفوسفور", "name_en": "Phosphorus", + "definition": "معدن ضروري للعظام والطاقة. طبيعي: 2.5-4.5 mg/dL.", + "high": "الفشل الكلوي، قصور الدريقات.", + "low": "سوء التغذية، مضادات الحموضة، فرط الدريقات.", + "symptoms_low": "ضعف عضلي، آلام عظام، تشوش ذهني.", + }, + { + "name_ar": "البروتين الكلي", "name_en": "Total Protein", + "definition": "مجموع البروتينات في الدم (ألبومين + جلوبيولين). طبيعي: 6.3-8.2 g/dL.", + "high": "الجفاف، أمراض المناعة، بعض السرطانات.", + "low": "سوء التغذية، أمراض الكبد والكلى.", + "symptoms_low": "ضعف، تورم، ضعف مناعة.", + }, + { + "name_ar": "الهيماتوكريت", "name_en": "Hematocrit (HCT)", + "definition": "نسبة حجم كريات الدم الحمراء من إجمالي الدم. طبيعي: رجال 38.3-48.6%، نساء 35.5-44.9%.", + "high": "الجفاف، كثرة الحمر، أمراض الرئة المزمنة.", + "low": "فقر الدم، فقدان الدم، الحمل.", + "symptoms_low": "تعب، ضيق تنفس، شحوب.", + }, + { + "name_ar": "متوسط حجم الكرية الحمراء", "name_en": "MCV (Mean Corpuscular Volume)", + "definition": "متوسط حجم كريات الدم الحمراء. طبيعي: 80-100 fL. يساعد في تصنيف نوع فقر الدم.", + "high": "فقر الدم الضخم الكريات (نقص B12 أو فولات)، الكحول، قصور الدرقية.", + "low": "فقر الدم الصغير الكريات (نقص حديد، ثلاسيميا).", + "symptoms_low": "أعراض فقر الدم مع تعب وشحوب.", + }, + { + "name_ar": "متوسط كمية الهيموجلوبين", "name_en": "MCH (Mean Corpuscular Hemoglobin)", + "definition": "كمية الهيموجلوبين في كل كرية حمراء. طبيعي: 27-33 pg.", + "high": "فقر الدم الضخم الكريات.", + "low": "فقر الدم الصغير الكريات، نقص الحديد.", + "symptoms_low": "يُفسر مع MCV وهيموجلوبين لتصنيف فقر الدم.", + }, + { + "name_ar": "تركيز الهيموجلوبين في الكرية", "name_en": "MCHC", + "definition": "تركيز الهيموجلوبين في كل كرية. طبيعي: 31.5-36 g/dL.", + "high": "فقر الدم الانحلالي الوراثي، الجفاف.", + "low": "نقص الحديد، الثلاسيميا.", + "symptoms_low": "أعراض فقر الدم.", + }, + { + "name_ar": "تباين حجم الكريات", "name_en": "RDW (Red Cell Distribution Width)", + "definition": "مقياس اختلاف أحجام كريات الدم الحمراء. طبيعي: 11.5-14.5%.", + "high": "نقص الحديد، B12، فولات، فقر الدم الانحلالي.", + "low": "غير ذي أهمية سريرية عادة.", + "symptoms_low": "يُستخدم مع MCV لتصنيف نوع فقر الدم بدقة.", + }, + { + "name_ar": "العدلات", "name_en": "Neutrophils", + "definition": "أكثر خلايا الدم البيضاء شيوعاً. طبيعي: 40-70% من WBC أو 1800-7800/µL.", + "high": "عدوى بكتيرية، التهاب، توتر، كورتيكوستيرويدات.", + "low": "عدوى فيروسية شديدة، أدوية، أمراض نخاع العظم.", + "symptoms_low": "خطر عالٍ للعدوى البكتيرية عند الانخفاض الشديد.", + }, + { + "name_ar": "الخلايا اللمفاوية", "name_en": "Lymphocytes", + "definition": "تنتج الأجسام المضادة وتحارب الفيروسات. طبيعي: 20-40% من WBC.", + "high": "عدوى فيروسية، ابيضاض اللمفاوية.", + "low": "HIV، الكورتيكوستيرويدات، العلاج الإشعاعي.", + "symptoms_low": "ضعف المناعة ضد الفيروسات.", + }, + { + "name_ar": "الوحيدات", "name_en": "Monocytes", + "definition": "تلتهم الجراثيم والخلايا الميتة. طبيعي: 2-8% من WBC.", + "high": "عدوى مزمنة، التهاب، السل، أمراض مناعية.", + "low": "نادر، قد يكون من الكورتيكوستيرويدات.", + "symptoms_low": "يُفسر مع بقية خلايا الدم البيضاء.", + }, + { + "name_ar": "القعدات", "name_en": "Basophils", + "definition": "أندر خلايا الدم البيضاء، مرتبطة بالحساسية. طبيعي: 0.5-1% من WBC.", + "high": "أمراض الدم، الحساسية المزمنة، قصور الدرقية.", + "low": "نادراً ما له أهمية سريرية.", + "symptoms_low": "الارتفاع الشديد قد يكون علامة ابيضاض دموي.", + }, + { + "name_ar": "السكر العشوائي", "name_en": "Random Blood Sugar (RBS)", + "definition": "قياس السكر في أي وقت. الطبيعي: أقل من 140 mg/dL. مقلق: 140-199. سكري: 200 فأكثر مع أعراض.", + "high": "مرض السكري، الإجهاد، بعض الأدوية.", + "low": "نقص سكر الدم، جرعة أنسولين زائدة.", + "symptoms_low": "الارتفاع مع أعراض (عطش، كثرة تبول) يؤكد السكري.", + }, + { + "name_ar": "اختبار تحمل الجلوكوز", "name_en": "OGTT (Oral Glucose Tolerance Test)", + "definition": "يشرب المريض 75 غرام جلوكوز ويُقاس السكر بعد ساعتين. طبيعي: أقل من 140. ما قبل السكري: 140-199. سكري: 200+.", + "high": "سكري، مقاومة أنسولين، سكري الحمل.", + "low": "لا أهمية سريرية للانخفاض.", + "symptoms_low": "أفضل اختبار للكشف المبكر عن السكري وسكري الحمل.", + }, + { + "name_ar": "سي ببتيد", "name_en": "C-Peptide", + "definition": "ناتج ثانوي من إنتاج الأنسولين. يميز بين السكري النوع الأول والثاني. طبيعي: 0.5-2.0 ng/mL.", + "high": "مقاومة الأنسولين، ورم الأنسولين، السكري النوع الثاني.", + "low": "السكري النوع الأول، البنكرياس الضعيف.", + "symptoms_low": "C-Peptide منخفض = البنكرياس لا ينتج أنسولين = يحتاج أنسولين خارجي.", + }, + { + "name_ar": "الكوليسترول منخفض الكثافة جداً", "name_en": "VLDL Cholesterol", + "definition": "يحمل الدهون الثلاثية للأنسجة. يُحسب: VLDL = Triglycerides ÷ 5. طبيعي: 2-30 mg/dL.", + "high": "ارتفاع الدهون الثلاثية، السكري، السمنة.", + "low": "لا أهمية سريرية.", + "symptoms_low": "ارتفاعه يزيد خطر أمراض القلب.", + }, + { + "name_ar": "جاما جلوتاميل ترانسفيريز", "name_en": "GGT (Gamma-Glutamyl Transferase)", + "definition": "إنزيم كبدي حساس للكحول والأدوية. طبيعي: رجال 8-61 U/L، نساء 5-36 U/L.", + "high": "الكحول، أمراض الكبد، بعض الأدوية، الكبد الدهني.", + "low": "لا أهمية سريرية.", + "symptoms_low": "مؤشر مبكر لتأثير الكحول على الكبد.", + }, + { + "name_ar": "البيليروبين المباشر", "name_en": "Direct (Conjugated) Bilirubin", + "definition": "البيليروبين المعالج بالكبد. طبيعي: 0-0.3 mg/dL.", + "high": "انسداد القنوات الصفراوية، التهاب الكبد، حصوات المرارة.", + "low": "لا أهمية سريرية.", + "symptoms_low": "ارتفاعه مع اليرقان يشير لمشكلة في تصريف الصفراء.", + }, + { + "name_ar": "اليوريا في الدم", "name_en": "BUN / Blood Urea Nitrogen", + "definition": "ناتج تكسير البروتين. BUN طبيعي: 7-20 mg/dL. يوريا: 2.5-7.1 mmol/L.", + "high": "ضعف الكلى، الجفاف، نزيف الجهاز الهضمي، أكل بروتين زائد.", + "low": "أمراض الكبد الشديدة، سوء التغذية.", + "symptoms_low": "نسبة BUN/Creatinine تحدد سبب ارتفاع البول النيتروجيني.", + }, + { + "name_ar": "الكلوريد", "name_en": "Chloride (Cl)", + "definition": "معدن يوازن السوائل والحموضة. طبيعي: 98-106 mEq/L.", + "high": "الجفاف، الحماض الكلوي، بعض الأدوية.", + "low": "القيء المتكرر، القصور الكلوي، الأدوية.", + "symptoms_low": "يُفسر دائماً مع الصوديوم والبوتاسيوم.", + }, + { + "name_ar": "هرمون T3 الحر", "name_en": "Free T3 (Triiodothyronine)", + "definition": "الهرمون الدرقي النشط. Free T3 طبيعي: 2.3-4.2 pg/mL.", + "high": "فرط نشاط الدرقية، التهاب الدرقية.", + "low": "قصور الدرقية، الأمراض الحادة.", + "symptoms_low": "Free T3 أدق من T3 الكلي في تقييم وظيفة الدرقية.", + }, + { + "name_ar": "هرمون T4 الحر", "name_en": "Free T4 (Thyroxine)", + "definition": "الهرمون الدرقي الرئيسي المخزون. Free T4 طبيعي: 0.8-1.8 ng/dL.", + "high": "فرط نشاط الدرقية.", + "low": "قصور الدرقية.", + "symptoms_low": "يُقاس مع TSH لتقييم الغدة الدرقية بشكل كامل.", + }, + { + "name_ar": "الأجسام المضادة للغدة الدرقية", "name_en": "Anti-TPO Antibodies", + "definition": "أجسام مضادة تهاجم الغدة الدرقية. طبيعي: أقل من 34 IU/mL.", + "high": "التهاب الغدة الدرقية هاشيموتو، داء غريفز.", + "low": "طبيعي.", + "symptoms_low": "ارتفاعه يؤكد المنشأ المناعي لمرض الدرقية.", + }, + { + "name_ar": "البروجسترون", "name_en": "Progesterone", + "definition": "هرمون الحمل وما بعد الإباضة. يتغير بحسب مرحلة الدورة والحمل.", + "high": "الحمل، بعض الأورام، فرط نشاط الغدة الكظرية.", + "low": "قصور الجسم الأصفر، خطر الإجهاض، عدم الإباضة.", + "symptoms_low": "الانخفاض في الحمل المبكر يستدعي متابعة طبية.", + }, + { + "name_ar": "الهرمون اللوتيني", "name_en": "LH (Luteinizing Hormone)", + "definition": "يحفز الإباضة عند المرأة وإنتاج التستوستيرون عند الرجل.", + "high": "انقطاع الطمث، قصور المبيض، تكيس المبايض.", + "low": "قصور الغدة النخامية.", + "symptoms_low": "نسبة LH/FSH مهمة في تشخيص تكيس المبايض.", + }, + { + "name_ar": "الهرمون المنبه للجريب", "name_en": "FSH (Follicle Stimulating Hormone)", + "definition": "يحفز نضج البويضات والحيوانات المنوية.", + "high": "انقطاع الطمث، قصور المبيض أو الخصية.", + "low": "قصور الغدة النخامية.", + "symptoms_low": "FSH مرتفع يعني احتياطي المبيض منخفض.", + }, + { + "name_ar": "ديهيدرو إيبي أندروستيرون", "name_en": "DHEA-S", + "definition": "هرمون من الغدة الكظرية، سلف للهرمونات الجنسية. القيم تختلف بالعمر والجنس.", + "high": "أورام الكظرية، تكيس المبايض.", + "low": "قصور الكظرية، الشيخوخة.", + "symptoms_low": "الارتفاع عند النساء يسبب شعر زائد وحب شباب.", + }, + { + "name_ar": "النحاس", "name_en": "Copper (Serum)", + "definition": "معدن ضروري لإنزيمات عدة. طبيعي: 70-140 µg/dL.", + "high": "الحمل، موانع الحمل، أمراض الكبد، متلازمة ويلسون.", + "low": "سوء تغذية، سوء امتصاص.", + "symptoms_low": "نقصه يسبب فقر الدم وضعف المناعة وهشاشة العظام.", + }, + { + "name_ar": "السيلينيوم", "name_en": "Selenium", + "definition": "معدن مضاد للأكسدة يدعم الغدة الدرقية والمناعة. طبيعي: 70-150 µg/L.", + "high": "تسمم نادر من المكملات.", + "low": "سوء التغذية، أمراض الأمعاء.", + "symptoms_low": "ضعف مناعة، ضعف عضلي، اضطراب درقي.", + }, + { + "name_ar": "إنزيم القلب CK-MB", "name_en": "CK-MB (Creatine Kinase-MB)", + "definition": "إنزيم من عضلة القلب. يرتفع عند تلف القلب. طبيعي: أقل من 5% من CK الكلي.", + "high": "نوبة قلبية، التهاب عضلة القلب، صدمة شديدة.", + "low": "طبيعي.", + "symptoms_low": "يستخدم مع التروبونين لتأكيد النوبة القلبية.", + }, + { + "name_ar": "هرمون BNP القلبي", "name_en": "BNP / NT-proBNP", + "definition": "مؤشر فشل القلب. BNP طبيعي: أقل من 100 pg/mL.", + "high": "فشل القلب الاحتقاني، ارتفاع ضغط الدم الرئوي.", + "low": "طبيعي.", + "symptoms_low": "ارتفاعه مع ضيق تنفس يستدعي تقييم القلب فوراً.", + }, + { + "name_ar": "دي دايمر", "name_en": "D-Dimer", + "definition": "ناتج تكسير الجلطات. طبيعي: أقل من 500 ng/mL.", + "high": "جلطة وريدية، جلطة رئوية، التهاب شديد، حمل، سرطان.", + "low": "طبيعي — يستبعد الجلطة.", + "symptoms_low": "ارتفاعه مع أعراض الجلطة يستدعي تصوير طارئ.", + }, + { + "name_ar": "الأجسام المضادة للنواة", "name_en": "ANA (Anti-Nuclear Antibodies)", + "definition": "أجسام مضادة للخلايا، مؤشر الأمراض المناعية الذاتية.", + "high": "الذئبة الحمراء، التهاب المفاصل الروماتويدي، متلازمة شوغرن.", + "low": "سلبي = يستبعد أمراض مناعية ذاتية كثيرة.", + "symptoms_low": "نتيجة إيجابية تستدعي مزيداً من الفحوصات وليس تشخيصاً وحده.", + }, + { + "name_ar": "عامل الروماتويد", "name_en": "Rheumatoid Factor (RF)", + "definition": "جسم مضاد يوجد في الروماتويد. طبيعي: أقل من 20 IU/mL.", + "high": "التهاب المفاصل الروماتويدي، متلازمة شوغرن، أمراض كبدية.", + "low": "سلبي لا يستبعد الروماتويد بالضرورة (30% سلبي).", + "symptoms_low": "يُقرأ مع Anti-CCP لتشخيص الروماتويد.", + }, + { + "name_ar": "البروكالسيتونين", "name_en": "Procalcitonin (PCT)", + "definition": "مؤشر دقيق للعدوى البكتيرية. طبيعي: أقل من 0.1 ng/mL.", + "high": "إنتان (Sepsis)، عدوى بكتيرية شديدة.", + "low": "عدوى فيروسية أو لا عدوى.", + "symptoms_low": "يساعد على قرار إعطاء المضادات الحيوية من عدمه.", + }, + { + "name_ar": "زمن الثرومبوبلاستين الجزئي", "name_en": "aPTT", + "definition": "يقيس مسار التخثر الداخلي. طبيعي: 25-35 ثانية.", + "high": "نقص عوامل التخثر، الهيبارين، الهيموفيليا.", + "low": "خطر تجلط.", + "symptoms_low": "يُستخدم لمراقبة علاج الهيبارين.", + }, + { + "name_ar": "الفيبرينوجين", "name_en": "Fibrinogen", + "definition": "بروتين أساسي في التخثر. طبيعي: 200-400 mg/dL.", + "high": "التهاب، حمل، أمراض قلبية وعائية.", + "low": "أمراض الكبد، استهلاك الفيبرينوجين في الجلطات.", + "symptoms_low": "الانخفاض الشديد يسبب نزيفاً خطيراً.", + }, + { + "name_ar": "بروتين البول", "name_en": "Urine Protein / Microalbuminuria", + "definition": "وجود بروتين في البول. طبيعي: أقل من 150 mg/يوم. بروتين دقيق: 30-300 mg/يوم.", + "high": "أمراض الكلى، السكري، ارتفاع الضغط.", + "low": "بول طبيعي.", + "symptoms_low": "أول علامة لتأثير السكري وضغط الدم على الكلى.", + }, + { + "name_ar": "الكيتونات في البول", "name_en": "Urine Ketones", + "definition": "ناتج حرق الدهون. طبيعي: سلبي.", + "high": "سكري غير متحكم به (طارئ)، صيام طويل، نظام كيتوني.", + "low": "سلبي طبيعي.", + "symptoms_low": "وجود كيتونات مع سكر مرتفع = الحماض الكيتوني (طارئ طبي).", + }, + { + "name_ar": "الكثافة النوعية للبول", "name_en": "Urine Specific Gravity", + "definition": "يقيس تركيز البول. طبيعي: 1.005-1.030.", + "high": "جفاف، إفراز هرمون ADH الزائد.", + "low": "شرب ماء زائد، مرض السكري الكاذب، فشل كلوي.", + "symptoms_low": "يعكس قدرة الكلى على تركيز البول.", + }, + { + "name_ar": "تحليل البراز الكامل", "name_en": "Stool Analysis (Ova & Parasites)", + "definition": "فحص البراز بحثاً عن طفيليات، بكتيريا، دم، خلايا.", + "high": "وجود طفيليات أو كريات بيضاء يدل على عدوى.", + "low": "براز طبيعي بدون طفيليات أو دم.", + "symptoms_low": "عند إسهال مزمن، ألم بطن، فقدان وزن غير مبرر.", + }, + { + "name_ar": "الدم الخفي في البراز", "name_en": "FOBT (Fecal Occult Blood Test)", + "definition": "يكشف الدم غير المرئي في البراز. طبيعي: سلبي.", + "high": "قرحة معدية، نزيف معوي، سرطان القولون، بواسير.", + "low": "سلبي = لا نزيف ظاهر.", + "symptoms_low": "اختبار أساسي لتحري سرطان القولون عند من فوق 45 سنة.", + }, + { + "name_ar": "جرثومة المعدة بالبراز", "name_en": "H. Pylori Stool Antigen", + "definition": "يكشف بكتيريا هيليكوباكتر بيلوري المسببة لقرحة المعدة.", + "high": "إيجابي = وجود البكتيريا = يحتاج علاجاً.", + "low": "سلبي = لا عدوى.", + "symptoms_low": "أفضل من فحص الدم لأنه يكشف العدوى الحالية.", + }, + { + "name_ar": "التهاب الكبد B", "name_en": "Hepatitis B Surface Antigen (HBsAg)", + "definition": "يكشف عدوى التهاب الكبد B. طبيعي: سلبي.", + "high": "إيجابي = عدوى نشطة بالتهاب الكبد B.", + "low": "سلبي = لا عدوى. إيجابي HBsAb = محصّن بلقاح أو شُفي.", + "symptoms_low": "التهاب الكبد B قد يكون صامتاً لسنوات ثم يتطور لتليف.", + }, + { + "name_ar": "التهاب الكبد C", "name_en": "Hepatitis C Antibody (HCV Ab)", + "definition": "يكشف الأجسام المضادة لفيروس C. طبيعي: سلبي.", + "high": "إيجابي يستدعي تأكيداً بـ PCR.", + "low": "سلبي = لا عدوى.", + "symptoms_low": "التهاب الكبد C صامت غالباً، يُعالج بالكامل الآن.", + }, + { + "name_ar": "تحليل فيروس نقص المناعة", "name_en": "HIV Test (4th Generation)", + "definition": "يكشف فيروس HIV والأجسام المضادة له. طبيعي: سلبي.", + "high": "إيجابي يستدعي تأكيداً بـ Western Blot.", + "low": "سلبي = لا عدوى.", + "symptoms_low": "الكشف المبكر والعلاج يجعل مرضى HIV يعيشون حياة طبيعية.", + }, + { + "name_ar": "هرمون الحمل بيتا", "name_en": "Beta hCG", + "definition": "هرمون الحمل. فوق 25 mIU/mL = حمل. يضاعف كل 48-72 ساعة في الحمل الطبيعي.", + "high": "حمل، حمل خارج رحم، ورم الحمل.", + "low": "لا حمل، أو خطر إجهاض.", + "symptoms_low": "ارتفاع بطيء أو انخفاض يستدعي تقييم الحمل الخارج رحمي.", + }, + { + "name_ar": "مخزون المبيض AMH", "name_en": "AMH (Anti-Müllerian Hormone)", + "definition": "يعكس عدد البويضات المتبقية. طبيعي: 1-3.5 ng/mL (يختلف بالعمر).", + "high": "تكيس المبايض.", + "low": "انخفاض احتياطي المبيض، انقطاع الطمث المبكر.", + "symptoms_low": "AMH منخفض يعني صعوبة الحمل بالطرق الطبيعية.", + }, + { + "name_ar": "تحليل السائل المنوي", "name_en": "Semen Analysis (Spermogram)", + "definition": "يقيم جودة الحيوانات المنوية: العدد (>15 مليون/mL)، الحركة (>40%)، الشكل (>4%).", + "high": "لا أهمية سريرية للارتفاع.", + "low": "قلة الحيوانات المنوية أو ضعف حركتها = سبب للعقم الذكوري.", + "symptoms_low": "40% من حالات العقم سببها ذكوري جزئياً أو كلياً.", + }, + { + "name_ar": "الفوسفاتاز القلوية", "name_en": "ALP (Alkaline Phosphatase)", + "definition": "إنزيم من الكبد والعظام. طبيعي: 44-147 U/L.", + "high": "أمراض الكبد، أمراض العظام، فرط نشاط الغدة الدريقية.", + "low": "قصور الغدة الدريقية، فقر الدم الوخيم.", + "symptoms_low": "يُفسر مع ALT وAST لتحديد المصدر (كبد أم عظام).", + }, + { + "name_ar": "الصوديوم", "name_en": "Sodium (Na)", + "definition": "أهم معدن في السائل خارج الخلايا. طبيعي: 136-145 mEq/L.", + "high": "جفاف، إسهال، مدرات البول، فرط نشاط الغدة الكظرية.", + "low": "إكثار الماء، فشل قلبي، قصور الغدة الكظرية، بعض الأدوية.", + "symptoms_low": "الانخفاض: صداع، غثيان، تشنج، ارتباك. الارتفاع: عطش شديد، جفاف.", + }, + { + "name_ar": "البوتاسيوم", "name_en": "Potassium (K)", + "definition": "معدن حيوي لعمل القلب والعضلات. طبيعي: 3.5-5.0 mEq/L.", + "high": "فشل كلوي، مدرات بول حافظة للبوتاسيوم، تلف أنسجة.", + "low": "إسهال، قيء، مدرات البول، نقص تغذية.", + "symptoms_low": "الانخفاض: ضعف عضلي، تشنج، عدم انتظام القلب.", + }, + { + "name_ar": "الكالسيوم", "name_en": "Calcium (Total)", + "definition": "معدن أساسي للعظام والأعصاب والعضلات. طبيعي: 8.5-10.5 mg/dL.", + "high": "فرط نشاط الغدة الدريقية، السرطان، الجفاف.", + "low": "قصور الغدة الدريقية، نقص فيتامين D، الفشل الكلوي.", + "symptoms_low": "الانخفاض: تشنجات عضلية، تنميل، اختلاجات. الارتفاع: تعب، كثرة تبول، حصوات كلى.", + }, + { + "name_ar": "مؤشر PSA لسرطان البروستات", "name_en": "PSA (Prostate-Specific Antigen)", + "definition": "بروتين تنتجه البروستات. طبيعي: أقل من 4 ng/mL (يرتفع مع العمر).", + "high": "تضخم البروستات الحميد، التهاب البروستات، سرطان البروستات.", + "low": "لا أهمية سريرية.", + "symptoms_low": "PSA المرتفع لا يعني بالضرورة السرطان، يحتاج تقييماً إضافياً.", + }, +] + +HEALTH_RECOMMENDATIONS = [ + { + "topic": "فقر الدم والأنيميا", + "content": """نصائح لمرضى فقر الدم: +الغذاء: تناول الأطعمة الغنية بالحديد: اللحوم الحمراء، الكبدة، السبانخ، العدس، الفاصوليا، الحبوب المدعمة. +تناول فيتامين C مع وجبات الحديد لتعزيز الامتصاص (برتقال، فلفل). +تجنب القهوة والشاي مع وجبات الحديد. +الرياضة: مشي خفيف 20-30 دقيقة يومياً، تجنب الإجهاد الشديد. +المتابعة: فحص الدم كل 3 أشهر حتى التحسن. +الأدوية: مكملات الحديد تُؤخذ على معدة فارغة أو مع عصير برتقال.""", + }, + { + "topic": "ارتفاع الكوليسترول", + "content": """نصائح لخفض الكوليسترول: +الغذاء: قلل الدهون المشبعة (لحم أحمر دهني، زبدة، جبن دسم). أكثر من الألياف: شوفان، تفاح، كمثرى، شعير. زيت الزيتون بديل ممتاز للدهون. +الرياضة: 150 دقيقة هوائية أسبوعياً (مشي سريع، سباحة، دراجة). +الوزن: كل كيلوغرام تخسره يخفض LDL بنسبة 1%. +التدخين: الإقلاع يرفع HDL بنسبة 10%. +المتابعة: فحص كل 6 أشهر مع العلاج.""", + }, + { + "topic": "مرض السكري وارتفاع السكر", + "content": """نصائح إدارة مرض السكري: +الغذاء: قلل النشويات المكررة (أرز أبيض، خبز أبيض، سكر). اختر الحبوب الكاملة. وزّع الوجبات 5-6 وجبات صغيرة. +الرياضة: 30 دقيقة يومياً تخفض السكر بشكل فوري وتحسن حساسية الأنسولين. +المراقبة: قياس السكر بانتظام. HbA1c كل 3 أشهر. +القدم السكري: فحص القدمين يومياً، حذاء مريح، لا مشي حافياً. +الأهداف: سكر صائم 80-130، بعد الأكل أقل من 180، HbA1c أقل من 7%.""", + }, + { + "topic": "ارتفاع ضغط الدم", + "content": """نصائح للتحكم في ضغط الدم: +الملح: قلل الصوديوم إلى أقل من 2300 mg يومياً (ملعقة صغيرة). +الغذاء DASH: خضروات، فواكه، حليب قليل الدسم، قلل اللحوم الحمراء. +الوزن: خسارة 5 كغ تخفض الضغط 5-10 mmHg. +الرياضة: 30 دقيقة يومياً تخفض الضغط 5-8 mmHg. +الإجهاد: تأمل، يوغا، تنفس عميق. +المتابعة: قياس الضغط يومياً في المنزل.""", + }, + { + "topic": "نقص فيتامين د", + "content": """نصائح لرفع مستوى فيتامين د: +الشمس: تعرض للشمس 15-30 دقيقة يومياً (بين 10 صباحاً و3 عصراً) على الذراعين والساقين. +الغذاء: سمك السلمون، السردين، صفار البيض، الحليب المدعم. +المكملات: عادة 1000-2000 IU يومياً (حسب توجيه الطبيب). +المتابعة: فحص بعد 3 أشهر من العلاج. +ملاحظة: فيتامين د يُمتص مع الدهون، تناوله مع وجبة دسمة.""", + }, + { + "topic": "التعب والإجهاد المزمن", + "content": """نصائح لمكافحة التعب المزمن: +فحوصات مقترحة: CBC، فيريتين، B12، فيتامين D، TSH، سكر الدم. +النوم: 7-9 ساعات، نظام نوم ثابت، تجنب الشاشات قبل النوم. +الغذاء: وجبات منتظمة، قلل السكر المكرر، أكثر من البروتين. +الرياضة: مشي 20 دقيقة يومياً يزيد الطاقة بشكل مثبت علمياً. +الترطيب: قلة الماء وحدها تسبب التعب، اشرب 8 أكواب يومياً.""", + }, + { + "topic": "صحة الغدة الدرقية", + "content": """نصائح للعناية بالغدة الدرقية: +اليود: ضروري لعمل الغدة (ملح معالج باليود، أسماك بحرية، أعشاب بحرية). +السيلينيوم: يدعم تحويل T4 إلى T3 (مكسرات برازيلية، سمك التونا). +قصور الدرقية: أدوية الليفوثيروكسين تُؤخذ على معدة فارغة 30-60 دقيقة قبل الإفطار. +تجنب فول الصويا والخضروات الصليبية (بروكلي، كرنب) بكميات كبيرة مع الأدوية. +المتابعة: فحص TSH كل 6-12 شهر مع العلاج.""", + }, + { + "topic": "صحة الكلى", + "content": """نصائح للحفاظ على صحة الكلى: +الترطيب: شرب 2-3 لتر ماء يومياً يحمي الكلى ويمنع الحصوات. +البروتين: تقليل البروتين الزائد عند ضعف الكلى (استشر طبيبك). +الملح: تقليل الصوديوم يحمي الكلى ويخفض الضغط. +السكر والضغط: ضبطهما يمنع 70% من حالات الفشل الكلوي. +الأدوية: تجنب مسكنات الألم (NSAIDs) بدون استشارة عند مشاكل الكلى. +المتابعة: فحص الكرياتينين وeGFR وبروتين البول بانتظام.""", + }, + { + "topic": "صحة الكبد", + "content": """نصائح للحفاظ على صحة الكبد: +الوزن: الكبد الدهني أكثر أمراض الكبد شيوعاً، تخسيس 10% من الوزن يحسنه كثيراً. +الكحول: هو السبب الأول لتليف الكبد في العالم، تجنبه تماماً. +الغذاء: خضروات، فواكه، قهوة (مفيدة للكبد)، قلل الدهون المشبعة والسكر. +الأدوية: لا تتناول أي دواء بدون استشارة عند مشاكل الكبد. +التطعيم: تأكد من أخذ لقاح التهاب الكبد B. +المتابعة: فحص ALT، AST، Bilirubin، Albumin كل 6 أشهر.""", + }, + { + "topic": "صحة القلب والأوعية الدموية", + "content": """نصائح لصحة القلب: +الغذاء: نظام البحر المتوسط (زيت زيتون، سمك، خضروات، مكسرات، حبوب كاملة). +أوميغا-3: السمك مرتين أسبوعياً أو مكملات 1000 mg يومياً تقلل الدهون الثلاثية. +الرياضة: 150 دقيقة هوائية أسبوعياً تقلل أمراض القلب بنسبة 35%. +التدخين: الإقلاع يخفض خطر النوبة القلبية بنسبة 50% خلال سنة. +الكوليسترول والضغط والسكر: ضبط الثلاثة أساس الوقاية من أمراض القلب. +المتابعة: فحص قلب مع ECG سنوياً بعد 40 سنة.""", + }, +] diff --git a/backend/medical_kb/__init__.py b/backend/medical_kb/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..880c3ab911a7ac991569a307129839a37b0a313d --- /dev/null +++ b/backend/medical_kb/__init__.py @@ -0,0 +1,6 @@ +from .reference.medical_reference import MedicalKB + +# Module-level singleton — import this across the app +kb = MedicalKB() + +__all__ = ["kb", "MedicalKB"] diff --git a/backend/medical_kb/reference/__init__.py b/backend/medical_kb/reference/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..162c6a681df4b7d69e6fc834eae8285dbbb49d0c --- /dev/null +++ b/backend/medical_kb/reference/__init__.py @@ -0,0 +1,4 @@ +from .normal_ranges import lookup, classify, NORMAL_RANGES +from .medical_reference import MedicalKB + +__all__ = ["lookup", "classify", "NORMAL_RANGES", "MedicalKB"] diff --git a/backend/medical_kb/reference/medical_reference.py b/backend/medical_kb/reference/medical_reference.py new file mode 100644 index 0000000000000000000000000000000000000000..98fbfb38b95de9be98bf7c54e44171dcdc7bc4a0 --- /dev/null +++ b/backend/medical_kb/reference/medical_reference.py @@ -0,0 +1,136 @@ +""" +MedicalKB — runtime knowledge base loaded from JSON schemas. +Provides panel lookup, test lookup, and severity classification. +""" +from __future__ import annotations +import json +import pathlib + +_SCHEMAS_DIR = pathlib.Path(__file__).parent.parent / "schemas" + + +class MedicalKB: + + def __init__(self) -> None: + self._panels: dict[str, dict] = {} + self._load_all() + + def _load_all(self) -> None: + if not _SCHEMAS_DIR.exists(): + return + for path in sorted(_SCHEMAS_DIR.glob("*.json")): + try: + data = json.loads(path.read_text(encoding="utf-8")) + code = data.get("panel_code", path.stem).lower() + self._panels[code] = data + except Exception as e: + print(f"[MedicalKB] Failed to load {path.name}: {e}") + print(f"[MedicalKB] Loaded {len(self._panels)} panels: {list(self._panels)}") + + # ── Panel-level ──────────────────────────────────────────────────────── + + def get_panel(self, code: str) -> dict | None: + return self._panels.get(code.lower()) + + def list_panels(self) -> list[str]: + return list(self._panels.keys()) + + # ── Test-level ───────────────────────────────────────────────────────── + + def get_test(self, panel_code: str, test_name: str) -> dict | None: + panel = self.get_panel(panel_code) + if not panel: + return None + tests = panel.get("tests", {}) + name_lower = test_name.lower().strip() + for canonical, data in tests.items(): + if canonical.lower() == name_lower: + return data + aliases = [a.lower() for a in data.get("abbreviations", [])] + if name_lower in aliases: + return data + return None + + def find_test_panel(self, test_name: str) -> tuple[str, dict] | None: + """Search all panels for a test name. Returns (panel_code, test_data) or None.""" + name_lower = test_name.lower().strip() + for code, panel in self._panels.items(): + for canonical, data in panel.get("tests", {}).items(): + if canonical.lower() == name_lower: + return code, data + if name_lower in [a.lower() for a in data.get("abbreviations", [])]: + return code, data + return None + + # ── Range lookup ─────────────────────────────────────────────────────── + + def get_range( + self, panel_code: str, test_name: str, gender: str = "adult" + ) -> tuple[float, float] | None: + test = self.get_test(panel_code, test_name) + if not test: + return None + ranges = test.get("ranges", {}) + gender_key = f"adult_{gender}" if gender in ("male", "female") else gender + r = ranges.get(gender_key) or ranges.get("adult") + if r: + lo = r.get("low", float("-inf")) + hi = r.get("high", float("inf")) + return lo, hi + return None + + def classify( + self, panel_code: str, test_name: str, value: float, gender: str = "adult" + ) -> str: + rng = self.get_range(panel_code, test_name, gender) + if rng is None: + return "unknown" + lo, hi = rng + if value < lo: + return "low" + if value > hi: + return "high" + return "normal" + + def get_severity( + self, panel_code: str, test_name: str, value: float + ) -> str | None: + """Return human-readable severity label from severity_thresholds if defined.""" + test = self.get_test(panel_code, test_name) + if not test: + return None + thresholds = test.get("severity_thresholds", {}) + if not thresholds: + return None + if "critical_low" in thresholds and value <= thresholds["critical_low"]: + return "critical_low" + if "severe_low" in thresholds and value <= thresholds["severe_low"]: + return "severe_low" + if "critical_high" in thresholds and value >= thresholds["critical_high"]: + return "critical_high" + if "acute_liver_failure" in thresholds and value >= thresholds["acute_liver_failure"]: + return "acute_liver_failure" + if "severe_elevation" in thresholds and value >= thresholds["severe_elevation"]: + return "severe_elevation" + if "moderate_elevation_x10" in thresholds and value >= thresholds["moderate_elevation_x10"]: + return "moderate_high" + if "mild_elevation_x3" in thresholds and value >= thresholds["mild_elevation_x3"]: + return "mild_high" + return None + + # ── Context building ─────────────────────────────────────────────────── + + def build_panel_context(self, panel_code: str) -> str: + """Return a compact Arabic text summary of a panel's reference ranges.""" + panel = self.get_panel(panel_code) + if not panel: + return "" + lines = [f"لوحة {panel.get('name_ar', panel_code)}:"] + for test_name, data in panel.get("tests", {}).items(): + ranges = data.get("ranges", {}) + adult = ranges.get("adult") or ranges.get("adult_male") + if adult: + lo, hi = adult.get("low", "?"), adult.get("high", "?") + unit = data.get("unit", "") + lines.append(f" {test_name}: {lo}–{hi} {unit}") + return "\n".join(lines) diff --git a/backend/medical_kb/reference/normal_ranges.py b/backend/medical_kb/reference/normal_ranges.py new file mode 100644 index 0000000000000000000000000000000000000000..db7d96b00310f5e43157e6c4cfc2ea37399662d5 --- /dev/null +++ b/backend/medical_kb/reference/normal_ranges.py @@ -0,0 +1,130 @@ +""" +Fast in-memory normal range lookup. +Used for runtime validation — faster than loading JSON schemas. +""" +from __future__ import annotations + +# (low, high) per gender key. Keys are lowercase, underscored. +NORMAL_RANGES: dict[str, dict[str, tuple[float, float]]] = { + # CBC + "hemoglobin": {"adult_male": (13.5, 17.5), "adult_female": (12.0, 15.5), "adult": (11.0, 17.5), "children": (11.5, 15.5)}, + "hgb": {"adult_male": (13.5, 17.5), "adult_female": (12.0, 15.5), "adult": (11.0, 17.5)}, + "hb": {"adult_male": (13.5, 17.5), "adult_female": (12.0, 15.5), "adult": (11.0, 17.5)}, + "wbc": {"adult": (4.0, 11.0), "children": (5.0, 15.0)}, + "rbc": {"adult_male": (4.5, 5.9), "adult_female": (4.0, 5.2), "adult": (4.0, 5.9)}, + "platelets": {"adult": (150.0, 400.0)}, + "plt": {"adult": (150.0, 400.0)}, + "hematocrit": {"adult_male": (41.0, 53.0), "adult_female": (36.0, 48.0), "adult": (36.0, 53.0)}, + "hct": {"adult_male": (41.0, 53.0), "adult_female": (36.0, 48.0), "adult": (36.0, 53.0)}, + "mcv": {"adult": (80.0, 100.0)}, + "mch": {"adult": (27.0, 33.0)}, + "mchc": {"adult": (32.0, 36.0)}, + "neutrophils": {"adult": (50.0, 70.0)}, + "lymphocytes": {"adult": (20.0, 40.0)}, + "monocytes": {"adult": (2.0, 8.0)}, + "eosinophils": {"adult": (1.0, 4.0)}, + "basophils": {"adult": (0.0, 1.0)}, + + # Thyroid + "tsh": {"adult": (0.4, 4.0), "pregnant": (0.1, 2.5), "elderly": (0.4, 5.0)}, + "ft4": {"adult": (0.8, 1.8)}, + "free_t4": {"adult": (0.8, 1.8)}, + "ft3": {"adult": (2.3, 4.2)}, + "free_t3": {"adult": (2.3, 4.2)}, + "anti_tpo": {"adult": (0.0, 34.0)}, + "anti-tpo": {"adult": (0.0, 34.0)}, + + # Liver + "alt": {"adult_male": (7.0, 40.0), "adult_female": (7.0, 35.0), "adult": (7.0, 40.0)}, + "sgpt": {"adult_male": (7.0, 40.0), "adult_female": (7.0, 35.0), "adult": (7.0, 40.0)}, + "ast": {"adult_male": (10.0, 40.0), "adult_female": (10.0, 35.0), "adult": (10.0, 40.0)}, + "sgot": {"adult_male": (10.0, 40.0), "adult_female": (10.0, 35.0), "adult": (10.0, 40.0)}, + "alp": {"adult": (44.0, 147.0), "children": (50.0, 350.0)}, + "ggt": {"adult_male": (8.0, 61.0), "adult_female": (5.0, 36.0), "adult": (5.0, 61.0)}, + "bilirubin": {"adult": (0.2, 1.2)}, + "total_bilirubin":{"adult": (0.2, 1.2)}, + "direct_bilirubin":{"adult": (0.0, 0.3)}, + "albumin": {"adult": (3.5, 5.0)}, + "alb": {"adult": (3.5, 5.0)}, + "total_protein": {"adult": (6.0, 8.3)}, + + # Kidney + "creatinine": {"adult_male": (0.7, 1.3), "adult_female": (0.5, 1.1), "adult": (0.5, 1.3)}, + "bun": {"adult": (8.0, 25.0)}, + "urea": {"adult": (15.0, 45.0)}, + "uric_acid": {"adult_male": (3.4, 7.0), "adult_female": (2.4, 6.0), "adult": (2.4, 7.0)}, + "egfr": {"adult": (60.0, 999.0)}, + + # Lipids + "cholesterol": {"adult": (0.0, 200.0)}, + "ldl": {"adult": (0.0, 100.0)}, + "hdl": {"adult_male": (40.0, 999.0), "adult_female": (50.0, 999.0), "adult": (40.0, 999.0)}, + "triglycerides": {"adult": (0.0, 150.0)}, + "tg": {"adult": (0.0, 150.0)}, + + # Diabetes + "glucose": {"adult": (70.0, 99.0)}, + "fasting_glucose":{"adult": (70.0, 99.0)}, + "hba1c": {"adult": (4.0, 5.6)}, + "a1c": {"adult": (4.0, 5.6)}, + + # Electrolytes + "sodium": {"adult": (136.0, 145.0)}, + "potassium": {"adult": (3.5, 5.0)}, + "chloride": {"adult": (98.0, 106.0)}, + "calcium": {"adult": (8.5, 10.5)}, + "magnesium": {"adult": (1.7, 2.2)}, + "phosphorus": {"adult": (2.5, 4.5)}, + + # Iron studies + "ferritin": {"adult_male": (24.0, 336.0), "adult_female": (11.0, 307.0), "adult": (11.0, 336.0)}, + "iron": {"adult": (60.0, 170.0)}, + "tibc": {"adult": (250.0, 370.0)}, + + # Vitamins + "vitamin_d": {"adult": (20.0, 50.0)}, + "vitamin_b12": {"adult": (200.0, 900.0)}, + "b12": {"adult": (200.0, 900.0)}, + "folate": {"adult": (2.7, 17.0)}, + + # Inflammation + "crp": {"adult": (0.0, 5.0)}, + "esr": {"adult_male": (0.0, 15.0), "adult_female": (0.0, 20.0), "adult": (0.0, 20.0)}, + + # Coagulation + "pt": {"adult": (11.0, 13.5)}, + "inr": {"adult": (0.8, 1.2)}, + "aptt": {"adult": (25.0, 35.0)}, + + # Hormones + "prolactin": {"adult_male": (2.0, 18.0), "adult_female": (2.0, 29.0), "adult": (2.0, 29.0)}, + "testosterone": {"adult_male": (300.0, 1000.0), "adult_female": (15.0, 70.0)}, + "cortisol": {"adult": (5.0, 23.0)}, +} + + +def lookup(name: str, gender: str = "adult") -> tuple[float, float] | None: + """ + Fast range lookup. Returns (low, high) or None. + name: test name (case-insensitive, spaces → underscores) + gender: 'adult', 'adult_male', 'adult_female', 'children', 'pregnant', 'elderly' + """ + key = name.lower().strip().replace(" ", "_").replace("-", "_").replace("/", "_") + entry = NORMAL_RANGES.get(key) + if not entry: + return None + gender_key = f"adult_{gender}" if gender in ("male", "female") else gender + return entry.get(gender_key) or entry.get("adult") + + +def classify(name: str, value: float, gender: str = "adult") -> str: + """Return 'low', 'normal', 'high', or 'unknown'.""" + rng = lookup(name, gender) + if rng is None: + return "unknown" + lo, hi = rng + if value < lo: + return "low" + if value > hi: + return "high" + return "normal" diff --git a/backend/medical_kb/schemas/cbc.json b/backend/medical_kb/schemas/cbc.json new file mode 100644 index 0000000000000000000000000000000000000000..286504621cf09fd12c83d595e75ee5e3bb247d25 --- /dev/null +++ b/backend/medical_kb/schemas/cbc.json @@ -0,0 +1,153 @@ +{ + "panel_code": "cbc", + "name_ar": "صورة الدم الكاملة", + "name_en": "Complete Blood Count", + "specialty": "hematology", + "icd10_related": ["D50", "D51", "D52", "D53", "D64", "D69"], + "tests": { + "Hemoglobin": { + "name_ar": "هيموجلوبين", + "abbreviations": ["HGB", "Hgb", "Hb", "هيموجلوبين", "خضاب الدم"], + "unit": "g/dL", + "ranges": { + "adult_male": {"low": 13.5, "high": 17.5}, + "adult_female": {"low": 12.0, "high": 15.5}, + "children_6_12": {"low": 11.5, "high": 15.5}, + "pregnant": {"low": 11.0, "high": 14.0}, + "elderly_male": {"low": 12.5, "high": 17.0}, + "elderly_female":{"low": 11.5, "high": 15.0} + }, + "severity_thresholds": { + "critical_low": 7.0, + "severe_low": 8.0, + "mild_low_female": 11.0, + "mild_low_male": 12.5 + }, + "clinical_meaning_ar": "البروتين الذي يحمل الأكسجين في خلايا الدم الحمراء", + "high_causes_ar": ["الجفاف الشديد", "داء الكثرة الحمراء", "التدخين المزمن", "الإقامة في المرتفعات", "أمراض رئوية مزمنة"], + "low_causes_ar": ["نقص الحديد (الأكثر شيوعاً)", "نقص فيتامين B12", "نقص حمض الفوليك", "نزيف مزمن", "أمراض مزمنة (كلى، كبد)", "الثلاسيميا", "فقر الدم الانحلالي"], + "symptoms_low_ar": ["تعب وإرهاق غير معتاد", "شحوب الجلد والملتحمة", "ضيق تنفس عند المجهود", "خفقان وسرعة القلب", "دوخة وصداع", "برودة اليدين والقدمين", "هشاشة الأظافر وتساقط الشعر"], + "followup_tests": ["Ferritin", "Serum Iron", "TIBC", "Vitamin B12", "Folate", "Reticulocyte count", "Peripheral blood smear"], + "patient_explanation_ar": "الهيموجلوبين هو البروتين الحامل للأكسجين في دمك — مثل الحافلة التي تنقل الأكسجين من رئتيك لكل خلية في جسمك" + }, + "WBC": { + "name_ar": "خلايا الدم البيضاء", + "abbreviations": ["WBC", "Leukocytes", "TLC", "كريات بيضاء", "كريات الدم البيضاء"], + "unit": "10³/μL", + "ranges": { + "adult": {"low": 4.0, "high": 11.0}, + "children": {"low": 5.0, "high": 15.0}, + "neonates": {"low": 9.0, "high": 30.0} + }, + "severity_thresholds": { + "critical_low": 2.0, + "severe_low": 3.0, + "critical_high": 30.0, + "leukocytosis": 11.0, + "leukopenia": 4.0 + }, + "clinical_meaning_ar": "خلايا الجهاز المناعي التي تقاوم العدوى والمرض", + "high_causes_ar": ["عدوى بكتيرية (الأكثر شيوعاً)", "الالتهابات الحادة", "الإجهاد الجسدي الشديد", "بعض الأدوية (كورتيكوستيرويد)", "سرطان الدم (نادراً)", "تدخين السجائر"], + "low_causes_ar": ["عدوى فيروسية (إنفلونزا، كوفيد)", "أمراض نقص المناعة", "أدوية الكيموثيرابي", "نقص B12 أو الفوليك", "أمراض المناعة الذاتية", "تليف نخاع العظم"], + "followup_tests": ["Differential count (Neutrophils, Lymphocytes, Eosinophils)", "Blood culture if infection suspected", "CBC with differential"], + "patient_explanation_ar": "خلايا الدم البيضاء هي جيش جهازك المناعي — تحارب الجراثيم والفيروسات وتحمي جسمك" + }, + "RBC": { + "name_ar": "خلايا الدم الحمراء", + "abbreviations": ["RBC", "Erythrocytes", "كريات حمراء"], + "unit": "10⁶/μL", + "ranges": { + "adult_male": {"low": 4.5, "high": 5.9}, + "adult_female": {"low": 4.0, "high": 5.2} + }, + "clinical_meaning_ar": "الخلايا الحاملة للهيموجلوبين والأكسجين", + "high_causes_ar": ["الجفاف", "داء الكثرة الحمراء", "التدخين"], + "low_causes_ar": ["فقر الدم", "النزيف", "نقص المغذيات"], + "patient_explanation_ar": "هي الخلايا الحمراء الصغيرة التي تحمل الأكسجين في مجرى الدم — كل واحدة تعيش حوالي 120 يوماً" + }, + "Platelets": { + "name_ar": "الصفائح الدموية", + "abbreviations": ["PLT", "Thrombocytes", "صفائح", "صفيحات"], + "unit": "10³/μL", + "ranges": { + "adult": {"low": 150, "high": 400} + }, + "severity_thresholds": { + "critical_low": 20, + "severe_low": 50, + "thrombocytopenia": 150, + "thrombocytosis": 400 + }, + "clinical_meaning_ar": "خلايا صغيرة تسبق تكوين جلطة الجرح وتوقف النزيف", + "high_causes_ar": ["ردّ فعل لالتهاب أو عدوى", "نقص الحديد", "استئصال الطحال", "نقاوي أولي (نادر)"], + "low_causes_ar": ["فشل نخاع العظم", "أمراض الكبد", "الأدوية (الهيبارين، بعض المضادات الحيوية)", "أمراض المناعة الذاتية (ITP)", "نقص B12"], + "danger_signs_ar": "< 50: خطر نزيف تلقائي — < 20: خطر نزيف داخلي طارئ", + "patient_explanation_ar": "الصفائح خلايا صغيرة جداً تجتمع عند الجرح لتشكّل سدادة طبيعية توقف النزيف" + }, + "Hematocrit": { + "name_ar": "الهيماتوكريت", + "abbreviations": ["HCT", "PCV", "Hct"], + "unit": "%", + "ranges": { + "adult_male": {"low": 41, "high": 53}, + "adult_female": {"low": 36, "high": 48} + }, + "clinical_meaning_ar": "نسبة حجم خلايا الدم الحمراء من الحجم الكلي للدم — يتحرك دائماً مع الهيموجلوبين", + "patient_explanation_ar": "لو فصلنا دمك في أنبوب — الهيماتوكريت هو نسبة الجزء الأحمر (الخلايا) من إجمالي الدم" + }, + "MCV": { + "name_ar": "متوسط حجم الكريات الحمراء", + "abbreviations": ["MCV", "Mean Corpuscular Volume"], + "unit": "fL", + "ranges": { + "adult": {"low": 80, "high": 100} + }, + "interpretation_guide": { + "microcytic_low": "< 80 fL: كريات صغيرة → نقص الحديد أو الثلاسيميا", + "normocytic": "80-100 fL: كريات طبيعية الحجم", + "macrocytic_high": "> 100 fL: كريات كبيرة → نقص B12 أو الفوليك" + }, + "clinical_meaning_ar": "يُحدد نوع فقر الدم: صغير الحجم (حديدي) أو كبير الحجم (B12)", + "patient_explanation_ar": "يقيس حجم كل كرية دم حمراء — مفيد جداً لمعرفة سبب فقر الدم إذا وُجد" + }, + "MCH": { + "name_ar": "متوسط كمية هيموجلوبين الكرية", + "abbreviations": ["MCH"], + "unit": "pg", + "ranges": { + "adult": {"low": 27, "high": 33} + }, + "clinical_meaning_ar": "كمية الهيموجلوبين في كل خلية حمراء — يُكمل تفسير MCV" + }, + "Neutrophils": { + "name_ar": "العدلات", + "abbreviations": ["NEU", "NEUT", "Neutrophils", "PMN", "نيتروفيل"], + "unit": "%", + "ranges": { + "adult": {"low": 50, "high": 70} + }, + "clinical_meaning_ar": "أكثر خلايا الدم البيضاء شيوعاً — الخط الأول ضد العدوى البكتيرية", + "high_interpretation": "Neutrophilia (>70%) → bacterial infection / inflammation / steroid use", + "low_interpretation": "Neutropenia (<50%) → viral infection / bone marrow suppression" + }, + "Lymphocytes": { + "name_ar": "الخلايا الليمفاوية", + "abbreviations": ["LYM", "LYMPH", "لمفاوية"], + "unit": "%", + "ranges": { + "adult": {"low": 20, "high": 40} + }, + "clinical_meaning_ar": "تنتج الأجسام المضادة وتحفظ الذاكرة المناعية — ترتفع في العدوى الفيروسية" + }, + "Eosinophils": { + "name_ar": "الحمضات", + "abbreviations": ["EOS", "Eosinophils", "حمضات"], + "unit": "%", + "ranges": { + "adult": {"low": 1, "high": 4} + }, + "clinical_meaning_ar": "ترتفع في الحساسية والديدان الطفيلية", + "high_causes_ar": ["أمراض الحساسية (الربو، أكزيما)", "الديدان الطفيلية", "بعض الأدوية"] + } + } +} diff --git a/backend/medical_kb/schemas/diabetes.json b/backend/medical_kb/schemas/diabetes.json new file mode 100644 index 0000000000000000000000000000000000000000..236fdb4e8342c4599378969ba4730dd08a4228ff --- /dev/null +++ b/backend/medical_kb/schemas/diabetes.json @@ -0,0 +1,128 @@ +{ + "panel_code": "diabetes", + "name_ar": "سكري ومقاومة الإنسولين", + "name_en": "Diabetes & Glucose Metabolism Panel", + "specialty": "endocrinology", + "icd10_related": ["E11", "E10", "E13", "E14", "R73", "Z13.1"], + "tests": { + "Glucose": { + "name_ar": "سكر الدم الصيامي", + "abbreviations": ["FBS", "FBG", "Glucose", "Blood Sugar", "سكر الصيام", "سكر الدم"], + "unit": "mg/dL", + "ranges": { + "adult_normal": {"low": 70.0, "high": 99.0}, + "adult_prediabetes":{"low": 100.0, "high": 125.0}, + "adult": {"low": 70.0, "high": 99.0}, + "children": {"low": 70.0, "high": 100.0} + }, + "severity_thresholds": { + "hypoglycemia_critical": 54.0, + "hypoglycemia": 70.0, + "normal_upper": 99.0, + "prediabetes_lower": 100.0, + "prediabetes_upper": 125.0, + "diabetes_diagnosis": 126.0, + "hyperglycemia_severe": 300.0, + "dka_risk": 400.0, + "critical_high": 600.0 + }, + "clinical_meaning_ar": "مستوى الجلوكوز في الدم بعد 8 ساعات صيام — المقياس الأساسي لتشخيص السكري", + "interpretation_matrix": { + "< 54 mg/dL": "نقص سكر حاد — طارئ طبي فوري", + "54-69 mg/dL": "نقص سكر — تناول جلوكوز فوراً", + "70-99 mg/dL": "طبيعي", + "100-125 mg/dL": "ما قبل السكري (Prediabetes) — تغيير نمط الحياة ضروري", + ">= 126 mg/dL": "مشكوك بالسكري — يحتاج تأكيداً بفحص ثانٍ أو HbA1c", + ">= 300 mg/dL": "ارتفاع شديد — خطر الحماض الكيتوني" + }, + "high_causes_ar": ["مرض السكري النوع 1 أو 2", "ما قبل السكري", "الإجهاد الجسدي أو النفسي الشديد", "الكورتيزون والستيرويدات", "فرط نشاط الغدة الدرقية", "أمراض البنكرياس", "عدم الالتزام بالصيام قبل الفحص"], + "low_causes_ar": ["جرعة زائدة من الإنسولين أو أدوية السكري", "الصيام المطوّل", "ورم الأنسولين (Insulinoma)", "فشل الكبد الشديد", "قصور الغدة الكظرية"], + "symptoms_high_ar": ["عطش شديد وكثرة التبول", "تعب وضعف عام", "ضبابية الرؤية", "التئام بطيء للجروح", "تنميل في القدمين"], + "symptoms_low_ar": ["تعرق بارد ورجفة", "دوخة وضعف مفاجئ", "خفقان وتسارع القلب", "جوع شديد مفاجئ", "تشوش ذهني وتهيج"], + "followup_tests": ["HbA1c", "Fasting Insulin", "C-Peptide", "HOMA-IR", "Urine Microalbumin", "Lipid Panel"], + "patient_explanation_ar": "سكر الصيام مثل فحص درجة حرارة محرك سيارتك وهي باردة — يُظهر كيف يتعامل جسمك مع الجلوكوز في وضع الراحة" + }, + "HbA1c": { + "name_ar": "الهيموجلوبين السكري التراكمي", + "abbreviations": ["HbA1c", "A1C", "Glycated Hemoglobin", "سكر تراكمي", "الجليكوزيلاتد"], + "unit": "%", + "ranges": { + "adult_normal": {"low": 4.0, "high": 5.6}, + "adult_prediabetes":{"low": 5.7, "high": 6.4}, + "adult": {"low": 4.0, "high": 5.6}, + "diabetic_target": {"low": 0.0, "high": 7.0} + }, + "severity_thresholds": { + "normal": 5.6, + "prediabetes_lower": 5.7, + "prediabetes_upper": 6.4, + "diabetes_diagnosis": 6.5, + "good_control_diabetic": 7.0, + "poor_control": 8.0, + "very_poor_control": 10.0, + "critical": 14.0 + }, + "clinical_meaning_ar": "يعكس متوسط سكر الدم خلال الـ 90 يوم الماضية — لأن الهيموجلوبين يرتبط بالجلوكوز طوال عمر خلية الدم الحمراء", + "interpretation_matrix": { + "< 5.7%": "طبيعي — لا سكري", + "5.7-6.4%": "ما قبل السكري — خطر تطور السكري، تغيير نمط الحياة مطلوب", + ">= 6.5%": "سكري — يحتاج تأكيداً أو علاجاً", + "< 7% (مريض سكري)": "ضبط جيد للسكري", + "7-8% (مريض سكري)": "ضبط مقبول — تعديل العلاج", + "> 8% (مريض سكري)": "ضبط سيئ — خطر مضاعفات عالٍ", + "> 10%": "ضبط خطير جداً — مراجعة فورية" + }, + "high_causes_ar": ["مرض السكري غير المُسيطر عليه", "عدم الالتزام بالدواء أو الحمية", "فقر الدم الانحلالي (يُعطي قراءة كاذبة منخفضة)", "مرض الكلى المزمن (يرفع القراءة)"], + "low_causes_ar": ["فقر الدم الانحلالي (تُسرَّع خلايا الدم الحمراء — قراءة كاذبة منخفضة)", "نقص الحديد (قراءة كاذبة)", "تهيج خلايا الدم الحمراء"], + "followup_tests": ["Fasting Glucose", "Post-meal Glucose", "Fasting Insulin", "C-Peptide", "Kidney Function", "Lipid Panel", "Urine Microalbumin"], + "patient_explanation_ar": "HbA1c مثل كاميرا مُراقبة تسجّل سكرك لمدة 3 أشهر — بعكس سكر الصيام اليومي الذي يتأثر بيوم واحد" + }, + "Insulin": { + "name_ar": "الإنسولين الصيامي", + "abbreviations": ["Insulin", "Fasting Insulin", "إنسولين", "انسولين"], + "unit": "µIU/mL", + "ranges": { + "adult": {"low": 2.0, "high": 25.0}, + "adult_optimal": {"low": 2.0, "high": 10.0} + }, + "severity_thresholds": { + "optimal_upper": 10.0, + "normal_upper": 25.0, + "insulin_resistance_risk": 15.0, + "high": 30.0, + "critical_low": 1.0 + }, + "clinical_meaning_ar": "هرمون يُفرزه البنكرياس لإدخال الجلوكوز للخلايا — ارتفاعه الصيامي يشير لمقاومة الإنسولين", + "high_causes_ar": ["مقاومة الإنسولين (السكري النوع 2 المبكر)", "السمنة خاصةً الدهون البطنية", "متلازمة تكيس المبايض PCOS", "الخمول الجسدي", "ورم الأنسولين (Insulinoma) — نادر"], + "low_causes_ar": ["السكري النوع 1 (لا يُفرز البنكرياس كافياً)", "البنكرياس المتلف", "جرعة زائدة من أدوية السكري"], + "symptoms_high_ar": ["زيادة وزن مستمرة رغم الحمية", "تعب بعد الوجبات", "رغبة شديدة في الحلويات", "صعوبة خسارة الوزن", "أعراض PCOS عند النساء"], + "followup_tests": ["HOMA-IR", "HbA1c", "Fasting Glucose", "C-Peptide", "Testosterone (للنساء)", "Lipid Panel"], + "patient_explanation_ar": "الإنسولين مثل مفتاح يفتح خلاياك للجلوكوز — عندما ترتفع مستوياته صيامياً، يعني الأقفال بدأت تقاوم المفتاح" + }, + "HOMA_IR": { + "name_ar": "مؤشر مقاومة الإنسولين", + "abbreviations": ["HOMA-IR", "Insulin Resistance", "HOMA IR", "مقاومة الإنسولين"], + "unit": "حاصل", + "ranges": { + "adult_normal": {"low": 0.0, "high": 2.0}, + "adult": {"low": 0.0, "high": 2.0} + }, + "severity_thresholds": { + "normal": 2.0, + "borderline": 2.5, + "insulin_resistance": 3.0, + "severe": 5.0 + }, + "clinical_meaning_ar": "يُحسب من (الإنسولين × سكر الصيام) ÷ 405 — يقيس درجة مقاومة الإنسولين", + "interpretation_matrix": { + "HOMA-IR < 2.0": "حساسية إنسولين طبيعية", + "HOMA-IR 2.0-2.9": "مقاومة إنسولين حدية — انتبه للنمط الغذائي والحركة", + "HOMA-IR >= 3.0": "مقاومة إنسولين — خطر سكري النوع 2 ومتلازمة الأيض", + "HOMA-IR >= 5.0": "مقاومة إنسولين شديدة — تدخل طبي ضروري" + }, + "high_causes_ar": ["السمنة خاصةً الدهون البطنية", "الخمول الجسدي", "متلازمة الأيض", "PCOS", "النوم المضطرب وتوقف التنفس أثناء النوم", "النظام الغذائي الغني بالسكريات"], + "followup_tests": ["Fasting Glucose", "Insulin", "HbA1c", "Lipid Panel", "ALT (NAFLD)", "Testosterone (PCOS)"], + "patient_explanation_ar": "HOMA-IR رقم مُحسوب يُظهر مدى قدرة جسمك على الاستجابة للإنسولين — كلما ارتفع، كلما كان جسمك يقاوم التعليمات أكثر" + } + } +} diff --git a/backend/medical_kb/schemas/kidney.json b/backend/medical_kb/schemas/kidney.json new file mode 100644 index 0000000000000000000000000000000000000000..382c7f18c961eb060f5e8b7f95c4fe9786d6b8b2 --- /dev/null +++ b/backend/medical_kb/schemas/kidney.json @@ -0,0 +1,110 @@ +{ + "panel_code": "kidney", + "name_ar": "وظائف الكلى", + "name_en": "Kidney Function Tests", + "specialty": "nephrology", + "icd10_related": ["N17", "N18", "N19", "N28", "E11.65"], + "tests": { + "Creatinine": { + "name_ar": "كرياتينين", + "abbreviations": ["Cr", "Creat", "كرياتينين"], + "unit": "mg/dL", + "ranges": { + "adult_male": {"low": 0.7, "high": 1.3}, + "adult_female": {"low": 0.5, "high": 1.1}, + "adult": {"low": 0.5, "high": 1.3}, + "children": {"low": 0.3, "high": 0.7}, + "elderly_male": {"low": 0.7, "high": 1.4}, + "elderly_female":{"low": 0.5, "high": 1.2} + }, + "severity_thresholds": { + "critical_high": 10.0, + "severe_high": 5.0, + "moderate_high": 2.0, + "ckd_stage3": 2.0, + "ckd_stage4": 4.0 + }, + "clinical_meaning_ar": "نفاية عضلية تُفرز عبر الكلى — مؤشر مباشر لكفاءة الترشيح الكلوي", + "high_causes_ar": ["الفشل الكلوي الحاد أو المزمن", "الجفاف الشديد", "انسداد المسالك البولية", "الرياضة المكثفة", "ارتفاع تناول البروتين", "أدوية سامة للكلى (NSAIDs, أمينوغليكوزيد)", "رابدوميوليسيس (تحلل العضلات)"], + "low_causes_ar": ["ضمور العضلات أو فقدان الكتلة العضلية", "سوء التغذية الشديد", "الحمل (بسبب زيادة معدل الترشيح)", "كبار السن بكتلة عضلية قليلة"], + "symptoms_high_ar": ["انخفاض كمية البول أو توقفها", "تورم الأطراف والوجه", "غثيان وقيء", "تعب شديد وضعف عام", "ضيق تنفس", "تشوش ذهني في الحالات الشديدة"], + "followup_tests": ["eGFR", "BUN", "Urine Creatinine", "Urine Protein", "Electrolytes", "Urinalysis", "Kidney Ultrasound"], + "patient_explanation_ar": "الكرياتينين مثل مقياس دقة مرشّح الكلى — كلما ارتفع، كلما كان المرشّح أقل كفاءة" + }, + "BUN": { + "name_ar": "نيتروجين يوريا الدم", + "abbreviations": ["BUN", "Blood Urea Nitrogen", "يوريا"], + "unit": "mg/dL", + "ranges": { + "adult": {"low": 8.0, "high": 25.0}, + "elderly": {"low": 8.0, "high": 30.0}, + "children": {"low": 5.0, "high": 18.0} + }, + "severity_thresholds": { + "critical_high": 100.0, + "severe_high": 60.0, + "moderate_high": 40.0, + "uremia_risk": 80.0 + }, + "clinical_meaning_ar": "نفاية التحلل البروتيني تُفرز عبر الكلى — يرتفع مع الفشل الكلوي وارتفاع البروتين", + "high_causes_ar": ["الفشل الكلوي", "الجفاف", "نزيف الجهاز الهضمي العلوي", "ارتفاع تناول البروتين", "الحمى والإجهاد الشديد", "فشل القلب الاحتقاني"], + "low_causes_ar": ["سوء التغذية (نقص البروتين)", "أمراض الكبد الشديدة (يعيق تصنيع اليوريا)", "الحمل", "الإفراط في شرب السوائل"], + "symptoms_high_ar": ["غثيان وفقدان الشهية", "تعب وضعف", "حكة جلدية في الحالات الشديدة", "تشوش ذهني (يوريمية)"], + "followup_tests": ["Creatinine", "BUN/Creatinine ratio", "eGFR", "Urinalysis"], + "patient_explanation_ar": "BUN هو بقايا هضم البروتين — الكلية السليمة تُزيله بكفاءة، فإذا ارتفع يعني الكلية تتعب" + }, + "eGFR": { + "name_ar": "معدل الترشيح الكبيبي المقدّر", + "abbreviations": ["eGFR", "GFR", "معدل الترشيح"], + "unit": "mL/min/1.73m²", + "ranges": { + "adult": {"low": 60.0, "high": 999.0}, + "young_adult": {"low": 90.0, "high": 999.0} + }, + "severity_thresholds": { + "ckd_stage1": 90.0, + "ckd_stage2": 60.0, + "ckd_stage3a": 45.0, + "ckd_stage3b": 30.0, + "ckd_stage4": 15.0, + "ckd_stage5_dialysis": 15.0, + "critical_low": 10.0 + }, + "clinical_meaning_ar": "يقيس مدى قدرة الكلية على ترشيح الدم في الدقيقة — المقياس الذهبي لوظيفة الكلى", + "interpretation_matrix": { + "eGFR >= 90": "وظيفة كلوية طبيعية (CKD Stage 1 إذا كان مع علامات كلوية أخرى)", + "eGFR 60-89": "انخفاض طفيف — CKD Stage 2، تحتاج متابعة", + "eGFR 45-59": "انخفاض خفيف-معتدل — CKD Stage 3a، تعديل جرعات الأدوية", + "eGFR 30-44": "انخفاض معتدل-شديد — CKD Stage 3b، متابعة أمراض الكلى", + "eGFR 15-29": "انخفاض شديد — CKD Stage 4، التحضير للديلزة", + "eGFR < 15": "فشل كلوي — CKD Stage 5، ديلزة أو زراعة كلى" + }, + "low_causes_ar": ["مرض الكلى المزمن", "مرض السكري (اعتلال الكلية السكري)", "ارتفاع ضغط الدم المزمن", "التهاب الكلية", "انسداد المسالك البولية المزمن", "أدوية سامة للكلى"], + "followup_tests": ["Creatinine", "Urine Albumin/Creatinine ratio", "Electrolytes", "Phosphorus", "PTH", "Hemoglobin"], + "patient_explanation_ar": "eGFR يخبرك كم في المئة من كلاوي تعمل — 100% طبيعي، أقل من 15% تحتاج ديلزة" + }, + "Uric_Acid": { + "name_ar": "حمض اليوريك", + "abbreviations": ["UA", "Uric Acid", "حمض البول", "يورك"], + "unit": "mg/dL", + "ranges": { + "adult_male": {"low": 3.4, "high": 7.0}, + "adult_female": {"low": 2.4, "high": 6.0}, + "adult": {"low": 2.4, "high": 7.0}, + "children": {"low": 2.0, "high": 5.5} + }, + "severity_thresholds": { + "hyperuricemia_male": 7.0, + "hyperuricemia_female": 6.0, + "gout_risk": 8.0, + "critical_high": 12.0 + }, + "clinical_meaning_ar": "نفاية تحلل البورينات — يتراكم في المفاصل مسبباً النقرس عند ارتفاعه", + "high_causes_ar": ["النقرس (Gout)", "الفشل الكلوي", "الجفاف", "ارتفاع تناول الفركتوز واللحوم الحمراء", "بعض أدوية ضغط الدم (ثيازيد)", "كيموثيرابيا (تحلل الخلايا السرطانية)", "السمنة"], + "low_causes_ar": ["سوء التغذية", "أمراض الكبد الشديدة", "بعض الأدوية (الوبيورينول)", "نقص إنزيم Xanthine Oxidase (نادر)"], + "symptoms_high_ar": ["ألم مفاجئ وشديد في إبهام القدم (النقرس)", "احمرار وتورم المفاصل", "حصى الكلى (حمض اليوريك)", "ألم أسفل الظهر"], + "followup_tests": ["24hr Urine Uric Acid", "Creatinine", "eGFR", "Joint X-Ray"], + "patient_explanation_ar": "حمض اليوريك مثل رمل يتراكم في المفاصل — عندما يزيد يسبب نوبة ألم شديدة تُسمى النقرس" + } + } +} diff --git a/backend/medical_kb/schemas/lipid.json b/backend/medical_kb/schemas/lipid.json new file mode 100644 index 0000000000000000000000000000000000000000..5fbdedc50a7007d5b0a76e570aa999fbbbeee14a --- /dev/null +++ b/backend/medical_kb/schemas/lipid.json @@ -0,0 +1,105 @@ +{ + "panel_code": "lipid", + "name_ar": "الدهون والكوليسترول", + "name_en": "Lipid Panel", + "specialty": "cardiology", + "icd10_related": ["E78", "E78.0", "E78.1", "E78.2", "E78.5", "I25"], + "tests": { + "Cholesterol": { + "name_ar": "الكوليسترول الكلي", + "abbreviations": ["TC", "Total Cholesterol", "كوليسترول", "كولسترول"], + "unit": "mg/dL", + "ranges": { + "adult": {"low": 0.0, "high": 200.0}, + "borderline": {"low": 200.0, "high": 239.0}, + "high_risk": {"low": 240.0, "high": 999.0} + }, + "severity_thresholds": { + "desirable": 200.0, + "borderline_high": 239.0, + "high": 240.0, + "very_high": 300.0, + "critical_high": 400.0 + }, + "clinical_meaning_ar": "إجمالي الكوليسترول في الدم — يشمل LDL و HDL والأنواع الأخرى", + "high_causes_ar": ["النظام الغذائي الغني بالدهون المشبعة والمتحولة", "الوراثة (فرط كوليسترول الدم العائلي)", "قصور الغدة الدرقية", "مرض السكري غير المُسيطر عليه", "أمراض الكلى المزمنة", "بعض الأدوية (كورتيكوستيرويد، مدرات البول)"], + "low_causes_ar": ["سوء التغذية الشديد", "فرط نشاط الغدة الدرقية", "أمراض الكبد الشديدة", "فقر الدم الشديد"], + "symptoms_high_ar": ["عادةً بلا أعراض حتى حدوث مضاعفات", "ألم في الصدر (ذبحة صدرية)", "ألم الساقين عند المشي", "زانثوما (رواسب دهنية على الجلد في الحالات الشديدة)"], + "followup_tests": ["LDL", "HDL", "Triglycerides", "TSH", "Fasting Glucose", "HbA1c"], + "patient_explanation_ar": "الكوليسترول الكلي مثل مجموع رصيدك — لكن المهم توزيعه: هل هو كوليسترول جيد أم سيء" + }, + "LDL": { + "name_ar": "الكوليسترول الضار", + "abbreviations": ["LDL", "LDL-C", "كوليسترول LDL", "الكوليسترول الضار"], + "unit": "mg/dL", + "ranges": { + "adult_optimal": {"low": 0.0, "high": 100.0}, + "adult_near_optimal": {"low": 100.0, "high": 129.0}, + "adult_borderline": {"low": 130.0, "high": 159.0}, + "adult": {"low": 0.0, "high": 100.0} + }, + "severity_thresholds": { + "optimal": 100.0, + "near_optimal": 129.0, + "borderline_high": 159.0, + "high": 189.0, + "very_high": 190.0, + "target_high_risk_patient": 70.0 + }, + "clinical_meaning_ar": "الكوليسترول السيء — يترسب على جدران الشرايين مسبباً تصلب الشرايين وأمراض القلب", + "high_causes_ar": ["النظام الغذائي الغني بالدهون المشبعة", "الوراثة", "قصور الغدة الدرقية", "السمنة والخمول الجسدي", "مرض السكري", "التدخين"], + "low_causes_ar": ["استخدام أدوية الستاتين", "النظام الغذائي النباتي", "فرط نشاط الغدة الدرقية"], + "symptoms_high_ar": ["لا أعراض مباشرة", "على المدى البعيد: نوبة قلبية، سكتة دماغية، ضعف في الأطراف"], + "followup_tests": ["Total Cholesterol", "HDL", "Triglycerides", "hs-CRP", "Coronary Calcium Score"], + "patient_explanation_ar": "LDL هو الكوليسترول السيء — يُكدّس الترسبات في الشرايين مثل الصدأ في الأنابيب" + }, + "HDL": { + "name_ar": "الكوليسترول الجيد", + "abbreviations": ["HDL", "HDL-C", "كوليسترول HDL", "الكوليسترول الجيد"], + "unit": "mg/dL", + "ranges": { + "adult_male": {"low": 40.0, "high": 999.0}, + "adult_female": {"low": 50.0, "high": 999.0}, + "adult": {"low": 40.0, "high": 999.0}, + "optimal": {"low": 60.0, "high": 999.0} + }, + "severity_thresholds": { + "low_risk_male": 40.0, + "low_risk_female": 50.0, + "protective_high": 60.0, + "critical_low": 30.0 + }, + "clinical_meaning_ar": "الكوليسترول الجيد — يُزيل الكوليسترول الزائد من الشرايين ويعيده للكبد للتخلص منه", + "low_causes_ar": ["التدخين", "السمنة وقلة الحركة", "مرض السكري غير المُسيطر عليه", "الدهون الثلاثية المرتفعة جداً", "النظام الغذائي الغني بالكربوهيدرات المُكررة", "بعض الأدوية (بيتا بلوكر، ستيرويد)"], + "high_causes_ar": ["ممارسة الرياضة المنتظمة", "الإقلاع عن التدخين", "الدهون الأحادية غير المشبعة (زيت الزيتون)", "الكحول باعتدال (غير موصى به طبياً)"], + "symptoms_low_ar": ["لا أعراض مباشرة للانخفاض", "على المدى البعيد: زيادة خطر أمراض القلب"], + "followup_tests": ["Total Cholesterol", "LDL", "Triglycerides", "Fasting Glucose"], + "patient_explanation_ar": "HDL هو الكوليسترول الجيد — يعمل كمكنسة تُزيل الكوليسترول الزائد من الشرايين. كلما ارتفع، كان أفضل" + }, + "Triglycerides": { + "name_ar": "الدهون الثلاثية", + "abbreviations": ["TG", "TRIG", "Triglycerides", "دهون ثلاثية", "ثلاثيات الغليسريد"], + "unit": "mg/dL", + "ranges": { + "adult_normal": {"low": 0.0, "high": 150.0}, + "adult_borderline": {"low": 150.0, "high": 199.0}, + "adult_high": {"low": 200.0, "high": 499.0}, + "adult": {"low": 0.0, "high": 150.0} + }, + "severity_thresholds": { + "normal": 150.0, + "borderline": 199.0, + "high": 499.0, + "very_high": 500.0, + "pancreatitis_risk": 1000.0, + "critical": 2000.0 + }, + "clinical_meaning_ar": "الدهون الرئيسية المُخزَّنة في الجسم — ترتفع مع السكريات والكحول والخمول", + "high_causes_ar": ["النظام الغذائي الغني بالسكريات والكربوهيدرات المُكررة", "السمنة والخمول", "مرض السكري غير المُسيطر عليه", "الكحول", "قصور الغدة الدرقية", "الفشل الكلوي", "بعض الأدوية (ستيرويد، بيتا بلوكر)"], + "low_causes_ar": ["النظام الغذائي المنخفض الدهون والكربوهيدرات", "فرط نشاط الغدة الدرقية"], + "symptoms_high_ar": ["لا أعراض عادةً", "في الحالات الشديدة جداً: ألم البطن (التهاب البنكرياس)", "طفح جلدي دهني (زانثوما صفراء)", "ضبابية الدم (lipemia retinalis)"], + "followup_tests": ["Total Cholesterol", "LDL", "HDL", "Fasting Glucose", "HbA1c", "TSH"], + "patient_explanation_ar": "الدهون الثلاثية هي طاقة مُخزَّنة — ترتفع عندما تأكل سكريات أكثر مما يحتاجه جسمك" + } + } +} diff --git a/backend/medical_kb/schemas/liver.json b/backend/medical_kb/schemas/liver.json new file mode 100644 index 0000000000000000000000000000000000000000..a299f616bd6ec70c44ccff1b7bf6149568551e14 --- /dev/null +++ b/backend/medical_kb/schemas/liver.json @@ -0,0 +1,145 @@ +{ + "panel_code": "liver", + "name_ar": "وظائف الكبد", + "name_en": "Liver Function Tests", + "specialty": "hepatology", + "icd10_related": ["K70", "K71", "K72", "K73", "K74", "K75", "K76", "B15", "B16", "B17", "B18"], + "interpretation_axis": "Evaluate in 3 dimensions: hepatocellular (ALT/AST) + cholestatic (ALP/GGT/Bili) + synthetic (Albumin/PT)", + "tests": { + "ALT": { + "name_ar": "ناقلة أمين الألانين", + "abbreviations": ["ALT", "SGPT", "ALT/SGPT", "Alanine Aminotransferase"], + "unit": "U/L", + "ranges": { + "adult_male": {"low": 7, "high": 40}, + "adult_female": {"low": 7, "high": 35} + }, + "severity_thresholds": { + "mild_elevation_x3": 120, + "moderate_elevation_x10": 400, + "severe_elevation": 1000, + "acute_liver_failure": 3000 + }, + "clinical_meaning_ar": "الإنزيم الأكثر تخصصاً لخلايا الكبد — ارتفاعه يدل على تلفها مباشرةً", + "high_causes_ar": [ + "الكبد الدهني غير الكحولي (NAFLD) — الأكثر شيوعاً", + "التهاب الكبد الفيروسي (B وC وA)", + "التهاب الكبد الكحولي", + "الأدوية (باراسيتامول، ستاتينات، مضادات حيوية)", + "انسداد القنوات الصفراوية", + "قصور القلب (كبد احتقاني)" + ], + "severity_interpretation": { + "1_3x": "ارتفاع خفيف — متابعة وتغيير نمط الحياة", + "3_10x": "ارتفاع متوسط — تقييم طبي عاجل لتحديد السبب", + "above_10x": "⚠️ ارتفاع شديد — تلف كبدي حاد يستدعي طوارئ" + }, + "followup_tests": ["AST", "GGT", "ALP", "Bilirubin", "Albumin", "PT/INR", "Viral hepatitis panel (HBsAg, Anti-HCV)", "Liver ultrasound"], + "patient_explanation_ar": "ALT إنزيم يُفرز من داخل خلايا الكبد عند تلفها — كلما ارتفع أكثر كلما كان التلف أشد" + }, + "AST": { + "name_ar": "ناقلة أمين الأسبارتات", + "abbreviations": ["AST", "SGOT", "Aspartate Aminotransferase"], + "unit": "U/L", + "ranges": { + "adult_male": {"low": 10, "high": 40}, + "adult_female": {"low": 10, "high": 35} + }, + "clinical_meaning_ar": "أقل تخصصاً للكبد من ALT — يرتفع أيضاً مع أمراض القلب والعضلات", + "ast_alt_ratio": { + "ratio_2_to_1": "AST:ALT > 2:1 → يشير للتلف الكحولي بشكل خاص", + "alt_dominant": "ALT > AST → يشير لالتهاب فيروسي أو دهني", + "both_equal": "ارتفاع متساوٍ → التهاب غير محدد" + }, + "patient_explanation_ar": "AST موجود في الكبد والقلب والعضلات — ارتفاعه وحده لا يعني بالضرورة مشكلة كبدية" + }, + "ALP": { + "name_ar": "الفوسفاتاز القلوية", + "abbreviations": ["ALP", "Alkaline Phosphatase", "فوسفاتاز"], + "unit": "U/L", + "ranges": { + "adult": {"low": 44, "high": 147}, + "children": {"low": 50, "high": 350}, + "pregnant": {"low": 40, "high": 300} + }, + "clinical_meaning_ar": "يرتفع مع انسداد القنوات الصفراوية وأمراض العظام — يُقيَّم مع GGT", + "high_causes_ar": [ + "انسداد القنوات الصفراوية (حصى، ورم)", + "تليف الكبد الصفراوي الأولي (PBC)", + "أمراض العظام (داء بيجيت، كسور، ورم عظمي)", + "نمو طبيعي عند الأطفال والمراهقين", + "الثلث الثالث من الحمل (طبيعي)" + ], + "interpretation_with_GGT": "ALP مرتفع + GGT مرتفع → سبب كبدي/صفراوي | ALP مرتفع + GGT طبيعي → سبب عظمي", + "patient_explanation_ar": "هذا الإنزيم يوجد في الكبد وقنوات الصفراء والعظام — ارتفاعه يحتاج تفسيراً مع GGT" + }, + "GGT": { + "name_ar": "غاما غلوتاميل ترانسفيراز", + "abbreviations": ["GGT", "Gamma-GT", "Gamma-Glutamyl Transferase"], + "unit": "U/L", + "ranges": { + "adult_male": {"low": 8, "high": 61}, + "adult_female": {"low": 5, "high": 36} + }, + "clinical_meaning_ar": "أحساس مؤشر لأمراض الكبد الكحولية والانسداد الصفراوي — يساعد في تفسير ALP", + "high_causes_ar": ["الكحول (الأكثر تأثيراً)", "الكبد الدهني", "انسداد صفراوي", "أدوية معينة (فينيتوين، فاروفارين)"], + "diagnostic_value": "إذا ارتفع ALP مع ارتفاع GGT → مصدر الكبد. إذا ارتفع GGT وحده → غالباً الكحول أو الأدوية" + }, + "Bilirubin_Total": { + "name_ar": "البيليروبين الكلي", + "abbreviations": ["T.Bili", "Total Bilirubin", "بيليروبين كلي", "TBIL"], + "unit": "mg/dL", + "ranges": { + "adult": {"low": 0.2, "high": 1.2} + }, + "severity_thresholds": { + "visible_jaundice": 2.5, + "moderate_jaundice": 5.0, + "severe_jaundice": 15.0 + }, + "clinical_meaning_ar": "صبغة ناتجة عن تكسّر الهيموجلوبين — يعالجه الكبد ويُفرزه في الصفراء", + "high_causes_ar": [ + "التهاب الكبد الفيروسي", + "انسداد القنوات الصفراوية (حصى مرارة)", + "فقر الدم الانحلالي (تكسّر الدم)", + "متلازمة جيلبرت (حميدة — ترتفع مع الإجهاد والجوع)", + "تليف الكبد المتقدم" + ], + "patient_explanation_ar": "البيليروبين صبغة صفراء — عندما يرتفع يظهر اليرقان (اصفرار الجلد والعيون)" + }, + "Bilirubin_Direct": { + "name_ar": "البيليروبين المباشر", + "abbreviations": ["D.Bili", "Direct Bilirubin", "Conjugated Bilirubin"], + "unit": "mg/dL", + "ranges": { + "adult": {"low": 0.0, "high": 0.3} + }, + "clinical_meaning_ar": "البيليروبين المُعالَج من الكبد — ارتفاعه تحديداً يشير لمشكلة في الكبد أو الصفراء", + "interpretation": "Direct Bili > 50% من Total Bili → انسداد صفراوي أو أمراض الكبد الداخلية" + }, + "Albumin": { + "name_ar": "الألبومين", + "abbreviations": ["ALB", "Albumin", "ألبومين"], + "unit": "g/dL", + "ranges": { + "adult": {"low": 3.5, "high": 5.0} + }, + "severity_thresholds": { + "mild_low": 3.0, + "moderate_low": 2.5, + "severe_low": 2.0 + }, + "clinical_meaning_ar": "البروتين الأكثر إنتاجاً من الكبد — يعكس القدرة التركيبية للكبد وحالة التغذية", + "high_causes_ar": ["الجفاف (نسبياً)"], + "low_causes_ar": [ + "قصور الكبد المزمن أو التليف", + "سوء التغذية الشديد", + "متلازمة الكلاء (فقدان الألبومين في البول)", + "الالتهاب الحاد والمزمن", + "سوء الامتصاص" + ], + "clinical_importance": "الألبومين المنخفض هو من أقوى مؤشرات ضعف وظيفة الكبد التركيبية", + "patient_explanation_ar": "الألبومين بروتين يصنعه كبدك ويضخه للدم — انخفاضه دليل على أن الكبد يعمل بكفاءة أقل" + } + } +} diff --git a/backend/medical_kb/schemas/thyroid.json b/backend/medical_kb/schemas/thyroid.json new file mode 100644 index 0000000000000000000000000000000000000000..9ef3fc90ef322bfe1d7022dfcc0c09e27ba7a7dd --- /dev/null +++ b/backend/medical_kb/schemas/thyroid.json @@ -0,0 +1,94 @@ +{ + "panel_code": "thyroid", + "name_ar": "وظائف الغدة الدرقية", + "name_en": "Thyroid Function Tests", + "specialty": "endocrinology", + "icd10_related": ["E00", "E01", "E02", "E03", "E04", "E05", "E06"], + "interpretation_axis": "Always start from TSH — it is the most sensitive indicator", + "tests": { + "TSH": { + "name_ar": "الهرمون المحفز للغدة الدرقية", + "abbreviations": ["TSH", "Thyroid Stimulating Hormone", "ثيروتروبين"], + "unit": "mIU/L", + "ranges": { + "adult": {"low": 0.4, "high": 4.0}, + "pregnant_T1": {"low": 0.1, "high": 2.5}, + "pregnant_T2": {"low": 0.2, "high": 3.0}, + "pregnant_T3": {"low": 0.3, "high": 3.0}, + "elderly": {"low": 0.4, "high": 5.0}, + "children_1_6": {"low": 0.8, "high": 6.0}, + "neonates": {"low": 1.0, "high": 20.0} + }, + "severity_thresholds": { + "critical_low": 0.01, + "suppressed": 0.1, + "subclinical_high": 4.0, + "overt_high": 10.0 + }, + "clinical_meaning_ar": "هرمون النخامية الذي يتحكم في نشاط الغدة الدرقية — المؤشر الأول والأحساس", + "high_causes_ar": ["قصور الغدة الدرقية الأولي (هاشيموتو الأكثر شيوعاً)", "قصور ما بعد الولادة", "التهاب الغدة الدرقية تحت الحاد", "نقص اليود الشديد"], + "low_causes_ar": ["فرط نشاط الغدة الدرقية (مرض جريفز الأكثر شيوعاً)", "عقيدات درقية فاعلة", "التهاب الغدة الدرقية الصامت", "جرعة زائدة من الليفوثيروكسين"], + "interpretation_matrix": { + "TSH_high_FT4_low": "قصور درقي أولي — يحتاج ليفوثيروكسين", + "TSH_low_FT4_high": "فرط نشاط درقي واضح — يحتاج تقييماً متخصصاً عاجلاً", + "TSH_high_FT4_normal": "قصور تحت سريري — متابعة كل 3-6 أشهر", + "TSH_low_FT4_normal": "فرط نشاط تحت سريري — متابعة، علاج إذا ≥ 65 عاماً أو أعراض" + }, + "followup_tests": ["Free T4", "Free T3", "Anti-TPO", "Anti-Thyroglobulin", "Thyroid ultrasound"], + "patient_explanation_ar": "TSH هو الهرمون الذي يصدره دماغك لأمر الغدة الدرقية — مرتفع يعني الغدة كسولة، منخفض يعني نشيطة جداً" + }, + "Free_T4": { + "name_ar": "الثيروكسين الحر", + "abbreviations": ["FT4", "Free T4", "Free Thyroxine", "T4 الحر"], + "unit": "ng/dL", + "ranges": { + "adult": {"low": 0.8, "high": 1.8} + }, + "severity_thresholds": { + "critical_low": 0.4, + "critical_high": 3.5 + }, + "clinical_meaning_ar": "الهرمون الدرقي الرئيسي — ينظم الأيض والطاقة والحرارة والمزاج", + "high_causes_ar": ["فرط نشاط الغدة الدرقية", "جرعة زائدة من الليفوثيروكسين"], + "low_causes_ar": ["قصور الغدة الدرقية", "قصور النخامية (نادر)"], + "patient_explanation_ar": "T4 هو الهرمون الذي تنتجه الغدة الدرقية لتنظيم سرعة عمليات جسمك كلها" + }, + "Free_T3": { + "name_ar": "ثلاثي يودوثيرونين الحر", + "abbreviations": ["FT3", "Free T3", "Triiodothyronine"], + "unit": "pg/mL", + "ranges": { + "adult": {"low": 2.3, "high": 4.2} + }, + "clinical_meaning_ar": "الشكل النشط للهرمون الدرقي — يُحوَّل من T4 في الأنسجة", + "importance_note": "FT3 يُقاس عند الاشتباه بفرط نشاط الغدة أو متابعة العلاج — ليس ضرورياً في كل حالة", + "patient_explanation_ar": "T3 هو الشكل النشط لهرمون الغدة الدرقية — أكثر تأثيراً من T4 مباشرةً" + }, + "Anti_TPO": { + "name_ar": "أجسام مضادة للبيروكسيداز الدرقي", + "abbreviations": ["Anti-TPO", "TPOAb", "TPO antibodies", "أجسام ضد الدرقية"], + "unit": "IU/mL", + "ranges": { + "adult": {"low": 0, "high": 34} + }, + "severity_thresholds": { + "mildly_positive": 35, + "moderately_positive": 100, + "highly_positive": 500 + }, + "clinical_meaning_ar": "أجسام مناعية تستهدف الغدة الدرقية — ارتفاعها يشير لأمراض مناعة ذاتية", + "high_causes_ar": ["هاشيموتو (التهاب الغدة الدرقية المناعي الذاتي)", "مرض جريفز", "التهاب الغدة ما بعد الولادة"], + "clinical_notes": "Anti-TPO مرتفع مع TSH طبيعي → خطر تطوير قصور درقي في المستقبل (متابعة سنوية)", + "patient_explanation_ar": "جهازك المناعي أحياناً يخطئ ويهاجم غدتك الدرقية — هذه الأجسام المضادة هي دليل على ذلك" + }, + "Anti_TG": { + "name_ar": "أجسام مضادة للثيروجلوبولين", + "abbreviations": ["Anti-TG", "TgAb", "Thyroglobulin antibodies"], + "unit": "IU/mL", + "ranges": { + "adult": {"low": 0, "high": 115} + }, + "clinical_meaning_ar": "يُقاس مع Anti-TPO لتشخيص أمراض الغدة الدرقية المناعية — وبعد علاج سرطان الغدة" + } + } +} diff --git a/backend/medical_reference_schema.json b/backend/medical_reference_schema.json new file mode 100644 index 0000000000000000000000000000000000000000..99c4cdd17eb9b12242df442b0734428fc8acb656 --- /dev/null +++ b/backend/medical_reference_schema.json @@ -0,0 +1,418 @@ +{ + "_description": "Medical reference ranges for lab panels. Used to inject structured context into analysis prompts.", + "_version": "1.0", + "_last_updated": "2026-05-24", + "_sources": ["WHO", "LabCorp", "Mayo Clinic", "Harrison's Principles of Internal Medicine 21e"], + + "panels": { + "CBC": { + "name_ar": "صورة الدم الكاملة", + "name_en": "Complete Blood Count", + "code": "CBC", + "specialty": "hematology", + "tests": { + "Hemoglobin": { + "name_ar": "هيموجلوبين", + "abbreviations": ["HGB", "Hgb", "هيموجلوبين"], + "unit": "g/dL", + "ranges": { + "adult_male": {"low": 13.5, "high": 17.5}, + "adult_female": {"low": 12.0, "high": 15.5}, + "children_6_12":{"low": 11.5, "high": 15.5}, + "pregnant": {"low": 11.0, "high": 14.0} + }, + "clinical_meaning_ar": "البروتين الذي يحمل الأكسجين في خلايا الدم الحمراء", + "high_causes_ar": ["الجفاف (hemoconcentration)", "داء الكثرة الحمراء الحقيقية", "مرض رئوي مزمن (التعويض)"], + "low_causes_ar": ["نقص الحديد (السبب الأكثر شيوعاً)", "نقص فيتامين B12 أو حمض الفوليك", "نزيف مزمن", "أمراض مزمنة", "فقر الدم الانحلالي"], + "low_symptoms_ar": ["تعب وإرهاق", "شحوب الجلد والملتحمة", "ضيق تنفس عند المجهود", "خفقان", "دوخة"], + "when_to_worry": "< 8 g/dL يستدعي تقييماً طبياً عاجلاً", + "followup_tests": ["Ferritin", "Iron", "TIBC", "Vitamin B12", "Folate", "Reticulocyte count"] + }, + + "WBC": { + "name_ar": "خلايا الدم البيضاء", + "abbreviations": ["WBC", "Leukocytes", "كريات الدم البيضاء"], + "unit": "10³/μL", + "ranges": { + "adult": {"low": 4.0, "high": 11.0}, + "children": {"low": 5.0, "high": 15.0} + }, + "clinical_meaning_ar": "خلايا المناعة التي تقاوم العدوى والمرض", + "high_causes_ar": ["عدوى بكتيرية", "التهاب", "إجهاد جسدي شديد", "أمراض الدم (نادراً: سرطان الدم)"], + "low_causes_ar": ["عدوى فيروسية حادة", "أدوية (كيموثيرابي، إلخ)", "أمراض المناعة الذاتية", "نقص فيتامين B12"], + "when_to_worry": "> 30 أو < 2 يستدعي تقييماً طارئاً", + "critical_values": {"critical_high": 30.0, "critical_low": 2.0} + }, + + "Platelets": { + "name_ar": "الصفائح الدموية", + "abbreviations": ["PLT", "Thrombocytes", "صفائح"], + "unit": "10³/μL", + "ranges": { + "adult": {"low": 150, "high": 400} + }, + "clinical_meaning_ar": "خلايا صغيرة تساعد على تجلط الدم ووقف النزيف", + "high_causes_ar": ["التهابات", "نقص الحديد", "إزالة الطحال"], + "low_causes_ar": ["أمراض الكبد", "نقص فيتامين B12", "أدوية معينة", "أمراض المناعة الذاتية (ITP)"], + "when_to_worry": "< 50 خطر نزيف تلقائي; < 20 طوارئ", + "critical_values": {"critical_low": 20} + }, + + "RBC": { + "name_ar": "خلايا الدم الحمراء", + "abbreviations": ["RBC", "Erythrocytes"], + "unit": "10⁶/μL", + "ranges": { + "adult_male": {"low": 4.5, "high": 5.9}, + "adult_female": {"low": 4.0, "high": 5.2} + }, + "clinical_meaning_ar": "الخلايا التي تحمل الهيموجلوبين والأكسجين لأنسجة الجسم" + }, + + "Hematocrit": { + "name_ar": "الهيماتوكريت", + "abbreviations": ["HCT", "PCV"], + "unit": "%", + "ranges": { + "adult_male": {"low": 41, "high": 53}, + "adult_female": {"low": 36, "high": 48} + }, + "clinical_meaning_ar": "نسبة خلايا الدم الحمراء من حجم الدم الكلي" + }, + + "MCV": { + "name_ar": "متوسط حجم الكريات الحمراء", + "abbreviations": ["MCV"], + "unit": "fL", + "ranges": { + "adult": {"low": 80, "high": 100} + }, + "clinical_meaning_ar": "يساعد في تحديد نوع فقر الدم: صغير الحجم (نقص حديد) أو كبير الحجم (نقص B12)", + "interpretation": { + "microcytic_low_MCV": "< 80: يشير إلى نقص الحديد أو الثلاسيميا", + "normocytic": "80-100: فقر دم بسبب مرض مزمن أو فقدان دم حاد", + "macrocytic_high_MCV": "> 100: يشير إلى نقص B12 أو حمض الفوليك" + } + }, + + "MCH": { + "name_ar": "متوسط كمية هيموجلوبين الكرية", + "abbreviations": ["MCH"], + "unit": "pg", + "ranges": { + "adult": {"low": 27, "high": 33} + }, + "clinical_meaning_ar": "كمية الهيموجلوبين في كل خلية دم حمراء" + } + } + }, + + "Thyroid": { + "name_ar": "وظائف الغدة الدرقية", + "name_en": "Thyroid Function Tests", + "code": "TFT", + "specialty": "endocrinology", + "tests": { + "TSH": { + "name_ar": "الهرمون المحفز للدرقية", + "abbreviations": ["TSH", "Thyroid Stimulating Hormone"], + "unit": "mIU/L", + "ranges": { + "adult": {"low": 0.4, "high": 4.0}, + "pregnant_T1": {"low": 0.1, "high": 2.5}, + "pregnant_T2": {"low": 0.2, "high": 3.0}, + "pregnant_T3": {"low": 0.3, "high": 3.0}, + "elderly": {"low": 0.4, "high": 5.0} + }, + "clinical_meaning_ar": "هرمون من الغدة النخامية يتحكم في نشاط الغدة الدرقية", + "high_causes_ar": ["قصور الغدة الدرقية (hypothyroidism)", "التهاب الغدة الدرقية هاشيموتو"], + "low_causes_ar": ["فرط نشاط الغدة الدرقية (hyperthyroidism)", "مرض جريفز (Graves)"], + "interpretation": { + "TSH_high_FT4_low": "قصور الغدة الدرقية الأولي — تحتاج ليفوثيروكسين", + "TSH_low_FT4_high": "فرط نشاط الغدة الدرقية — يحتاج تقييماً متخصصاً", + "TSH_high_FT4_normal": "قصور درقي تحت السريري — متابعة كل 6 أشهر" + }, + "followup_tests": ["Free T4", "Free T3", "Anti-TPO", "Anti-Thyroglobulin"] + }, + + "Free_T4": { + "name_ar": "الثيروكسين الحر", + "abbreviations": ["FT4", "Free T4", "Thyroxine"], + "unit": "ng/dL", + "ranges": { + "adult": {"low": 0.8, "high": 1.8} + }, + "clinical_meaning_ar": "الهرمون الدرقي الرئيسي — ينظم الأيض والطاقة والحرارة" + }, + + "Free_T3": { + "name_ar": "ثلاثي يودوثيرونين الحر", + "abbreviations": ["FT3", "Free T3", "Triiodothyronine"], + "unit": "pg/mL", + "ranges": { + "adult": {"low": 2.3, "high": 4.2} + }, + "clinical_meaning_ar": "الشكل النشط للهرمون الدرقي — أقوى تأثيراً من T4" + }, + + "Anti_TPO": { + "name_ar": "أجسام مضادة للبيروكسيداز الدرقي", + "abbreviations": ["Anti-TPO", "TPO antibodies"], + "unit": "IU/mL", + "ranges": { + "adult": {"low": 0, "high": 34} + }, + "clinical_meaning_ar": "ارتفاعه يشير إلى التهاب مناعي ذاتي للغدة الدرقية (هاشيموتو)", + "high_causes_ar": ["هاشيموتو", "مرض جريفز", "التهاب الغدة الدرقية"] + } + } + }, + + "Liver": { + "name_ar": "وظائف الكبد", + "name_en": "Liver Function Tests", + "code": "LFT", + "specialty": "hepatology", + "tests": { + "ALT": { + "name_ar": "ناقلة أمين الألانين", + "abbreviations": ["ALT", "SGPT", "ALT/SGPT"], + "unit": "U/L", + "ranges": { + "adult_male": {"low": 7, "high": 40}, + "adult_female": {"low": 7, "high": 35} + }, + "clinical_meaning_ar": "إنزيم يُفرز عند تلف خلايا الكبد — الأكثر تخصصاً للكبد", + "high_causes_ar": [ + "التهاب الكبد الفيروسي (A, B, C)", + "الكبد الدهني", + "أمراض الكبد الكحولية", + "أدوية (باراسيتامول، الستاتينات بجرعات عالية)", + "انسداد القنوات الصفراوية" + ], + "severity": { + "mild_elevation": "< 3x upper limit: يستدعي المتابعة", + "moderate": "3-10x: يحتاج تقييماً طبياً", + "severe": "> 10x: تلف كبدي حاد — طوارئ" + }, + "followup_tests": ["AST", "GGT", "Bilirubin", "Albumin", "PT/INR", "Viral hepatitis panel"] + }, + + "AST": { + "name_ar": "ناقلة أمين الأسبارتات", + "abbreviations": ["AST", "SGOT"], + "unit": "U/L", + "ranges": { + "adult_male": {"low": 10, "high": 40}, + "adult_female": {"low": 10, "high": 35} + }, + "clinical_meaning_ar": "إنزيم أقل تخصصاً للكبد — يرتفع أيضاً مع أمراض القلب والعضلات", + "ast_alt_ratio": { + "ratio_2_1": "AST:ALT > 2:1 يشير إلى سبب كحولي", + "alt_higher": "ALT أعلى من AST يشير إلى التهاب كبدي فيروسي أو دهني" + } + }, + + "ALP": { + "name_ar": "الفوسفاتاز القلوية", + "abbreviations": ["ALP", "Alkaline Phosphatase"], + "unit": "U/L", + "ranges": { + "adult": {"low": 44, "high": 147}, + "children": {"low": 50, "high": 350} + }, + "clinical_meaning_ar": "يرتفع مع انسداد القنوات الصفراوية وأمراض العظام", + "high_causes_ar": ["انسداد القنوات الصفراوية", "حصى المرارة", "أمراض العظام (Paget's)", "نمو العظام الطبيعي عند الأطفال"] + }, + + "Bilirubin_Total": { + "name_ar": "البيليروبين الكلي", + "abbreviations": ["T.Bili", "Total Bilirubin", "بيليروبين كلي"], + "unit": "mg/dL", + "ranges": { + "adult": {"low": 0.2, "high": 1.2} + }, + "clinical_meaning_ar": "صبغة ناتجة عن تكسّر خلايا الدم الحمراء. يسبب اليرقان عند الارتفاع.", + "high_causes_ar": ["التهاب كبد", "انسداد صفراوي", "فقر الدم الانحلالي", "متلازمة جيلبرت (حميدة)"], + "jaundice_threshold": "> 2.5 mg/dL: يظهر اليرقان (اصفرار الجلد والعيون)" + }, + + "Bilirubin_Direct": { + "name_ar": "البيليروبين المباشر", + "abbreviations": ["D.Bili", "Direct Bilirubin", "Conjugated Bilirubin"], + "unit": "mg/dL", + "ranges": { + "adult": {"low": 0, "high": 0.3} + }, + "clinical_meaning_ar": "يرتفع مع أمراض الكبد والانسداد الصفراوي تحديداً" + }, + + "Albumin": { + "name_ar": "الألبومين", + "abbreviations": ["ALB", "Albumin"], + "unit": "g/dL", + "ranges": { + "adult": {"low": 3.5, "high": 5.0} + }, + "clinical_meaning_ar": "بروتين ينتجه الكبد — مؤشر لوظيفة الكبد التركيبية وسوء التغذية", + "low_causes_ar": ["قصور الكبد المزمن", "سوء التغذية", "متلازمة الكلاء", "التهاب حاد"], + "importance": "انخفاضه يدل على ضعف قدرة الكبد التركيبية — مهم جداً في تقييم الكبد" + }, + + "GGT": { + "name_ar": "غاما غلوتاميل ترانسفيراز", + "abbreviations": ["GGT", "Gamma-GT"], + "unit": "U/L", + "ranges": { + "adult_male": {"low": 8, "high": 61}, + "adult_female": {"low": 5, "high": 36} + }, + "clinical_meaning_ar": "حساس جداً لأمراض الكبد وخاصة الكحولية. يساعد في تفسير ارتفاع ALP.", + "high_causes_ar": ["الكحول", "أمراض الكبد الدهني", "أدوية معينة", "انسداد صفراوي"] + } + } + }, + + "Kidney": { + "name_ar": "وظائف الكلى", + "name_en": "Kidney Function Tests", + "code": "KFT", + "specialty": "nephrology", + "tests": { + "Creatinine": { + "name_ar": "كرياتينين", + "abbreviations": ["Creat", "Creatinine", "كرياتينين"], + "unit": "mg/dL", + "ranges": { + "adult_male": {"low": 0.7, "high": 1.3}, + "adult_female": {"low": 0.5, "high": 1.1} + }, + "clinical_meaning_ar": "مادة نفايات من العضلات تُصفيها الكلى — ارتفاعه يدل على ضعف وظيفة الكلى", + "high_causes_ar": ["الفشل الكلوي", "الجفاف", "كتلة عضلية كبيرة", "بعض الأدوية"], + "followup_tests": ["BUN", "GFR", "Urine albumin", "Electrolytes"] + }, + + "BUN": { + "name_ar": "نيتروجين يوريا الدم", + "abbreviations": ["BUN", "Blood Urea Nitrogen", "يوريا"], + "unit": "mg/dL", + "ranges": { + "adult": {"low": 8, "high": 25} + }, + "clinical_meaning_ar": "مادة نفايات من البروتينات — يرتفع مع ضعف الكلى أو نزيف الجهاز الهضمي", + "bun_creatinine_ratio": { + "normal": "10:1 إلى 20:1", + "high_ratio_pre_renal": "> 20:1: يشير إلى الجفاف أو نزيف هضمي", + "low_ratio": "< 10:1: يشير إلى نقص البروتين أو أمراض الكبد" + } + } + } + }, + + "Lipid": { + "name_ar": "الدهنيات", + "name_en": "Lipid Panel", + "code": "LIPID", + "specialty": "cardiology", + "tests": { + "Cholesterol_Total": { + "name_ar": "الكوليسترول الكلي", + "abbreviations": ["Total Cholesterol", "Chol", "كوليسترول"], + "unit": "mg/dL", + "ranges": { + "desirable": {"high": 200}, + "borderline": {"low": 200, "high": 239}, + "high_risk": {"low": 240} + }, + "clinical_meaning_ar": "دهون ضرورية للجسم — ارتفاعها يزيد خطر أمراض القلب والأوعية" + }, + + "LDL": { + "name_ar": "الكوليسترول الضار", + "abbreviations": ["LDL", "LDL-C", "LDL Cholesterol"], + "unit": "mg/dL", + "ranges": { + "optimal": {"high": 100}, + "near_optimal": {"low": 100, "high": 129}, + "borderline_high": {"low": 130, "high": 159}, + "high": {"low": 160, "high": 189}, + "very_high": {"low": 190} + }, + "targets_by_risk": { + "high_cv_risk": "< 70 mg/dL", + "moderate_risk": "< 100 mg/dL", + "low_risk": "< 130 mg/dL" + }, + "clinical_meaning_ar": "الكوليسترول الضار — يتراكم في جدران الأوعية ويزيد خطر النوبات القلبية" + }, + + "HDL": { + "name_ar": "الكوليسترول الجيد", + "abbreviations": ["HDL", "HDL-C", "HDL Cholesterol"], + "unit": "mg/dL", + "ranges": { + "low_risk_male": {"low": 40}, + "low_risk_female": {"low": 50}, + "protective": {"low": 60} + }, + "clinical_meaning_ar": "الكوليسترول الجيد — ينقل الدهون من الأوعية للكبد ويحمي القلب", + "note": "الأعلى أفضل — < 40 للرجال و< 50 للنساء عامل خطر قلبي" + }, + + "Triglycerides": { + "name_ar": "الدهون الثلاثية", + "abbreviations": ["TG", "TRIG", "Triglycerides"], + "unit": "mg/dL", + "ranges": { + "normal": {"high": 150}, + "borderline_high": {"low": 150, "high": 199}, + "high": {"low": 200, "high": 499}, + "very_high": {"low": 500} + }, + "high_causes_ar": ["السمنة", "السكري غير المنضبط", "قلة النشاط", "النظام الغذائي الغني بالسكر والكربوهيدرات", "قصور الغدة الدرقية"] + } + } + }, + + "Diabetes": { + "name_ar": "مؤشرات السكري", + "name_en": "Diabetes Markers", + "code": "DM", + "specialty": "endocrinology", + "tests": { + "Fasting_Glucose": { + "name_ar": "سكر الصيام", + "abbreviations": ["FBG", "FPG", "Fasting Glucose", "سكر صيام"], + "unit": "mg/dL", + "ranges": { + "normal": {"high": 99}, + "prediabetes":{"low": 100, "high": 125}, + "diabetes": {"low": 126} + }, + "note": "يجب الصيام 8-12 ساعة قبل الفحص للحصول على نتيجة دقيقة" + }, + + "HbA1c": { + "name_ar": "السكر التراكمي", + "abbreviations": ["HbA1c", "A1C", "Glycated Hemoglobin", "سكر تراكمي"], + "unit": "%", + "ranges": { + "normal": {"high": 5.6}, + "prediabetes":{"low": 5.7, "high": 6.4}, + "diabetes": {"low": 6.5} + }, + "targets_for_diabetics": { + "general": "< 7%", + "elderly": "< 8%", + "pregnant": "< 6%", + "cardiovascular":"< 7% or individualized" + }, + "clinical_meaning_ar": "يعكس متوسط مستوى السكر في الدم خلال الأشهر الثلاثة الماضية", + "avg_glucose_conversion": { + "5": 97, "6": 126, "7": 154, "8": 183, "9": 212, "10": 240, "11": 269, "12": 298 + } + } + } + } + } +} diff --git a/backend/middleware/__init__.py b/backend/middleware/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f1b1397e6f1055fe4e496a638b79c1c309fe8cac --- /dev/null +++ b/backend/middleware/__init__.py @@ -0,0 +1,4 @@ +from .audit import AuditMiddleware, get_audit_logger +from .sanitizer import validate_upload, sanitize_text, MAX_FILE_BYTES + +__all__ = ["AuditMiddleware", "get_audit_logger", "validate_upload", "sanitize_text", "MAX_FILE_BYTES"] diff --git a/backend/middleware/audit.py b/backend/middleware/audit.py new file mode 100644 index 0000000000000000000000000000000000000000..63b7dbbfe3cbad9502c3582c857a2c2aff225f58 --- /dev/null +++ b/backend/middleware/audit.py @@ -0,0 +1,136 @@ +""" +Structured audit logging middleware for PDPL/NDMO compliance. + +Every request is logged with: timestamp, method, path, client IP, +session_id (from header), status code, latency, and error summary. +Logs are written to a rotating file + stdout (structured JSON). +""" +from __future__ import annotations + +import json +import logging +import os +import time +import uuid +from logging.handlers import RotatingFileHandler +from pathlib import Path +from typing import Callable + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp + +# ── Audit logger (separate from app logger) ───────────────────────────────── + +_LOG_DIR = Path(os.getenv("AUDIT_LOG_DIR", "logs/audit")) +_LOG_FILE = _LOG_DIR / "audit.jsonl" +_MAX_BYTES = 50 * 1024 * 1024 # 50 MB per file +_BACKUP_COUNT = 10 # keep 10 rotated files (~500 MB total) + +# Paths that are not audited (health checks, static assets) +_SKIP_PATHS = frozenset(["/health", "/docs", "/openapi.json", "/favicon.ico"]) + +# Endpoints that handle medical data — flag them for compliance +_SENSITIVE_PATHS = frozenset([ + "/api/analyze", "/api/analyses/save", "/api/chat", + "/api/voice/transcribe", "/api/voice/chat", "/api/risk", +]) + + +def _build_audit_logger() -> logging.Logger: + logger = logging.getLogger("audit") + if logger.handlers: + return logger + logger.setLevel(logging.INFO) + logger.propagate = False + + try: + _LOG_DIR.mkdir(parents=True, exist_ok=True) + fh = RotatingFileHandler( + _LOG_FILE, maxBytes=_MAX_BYTES, backupCount=_BACKUP_COUNT, encoding="utf-8" + ) + fh.setFormatter(logging.Formatter("%(message)s")) + logger.addHandler(fh) + except Exception as e: + print(f"[AUDIT] Cannot open log file {_LOG_FILE}: {e} — writing to stderr only") + + sh = logging.StreamHandler() + sh.setFormatter(logging.Formatter("%(message)s")) + logger.addHandler(sh) + + return logger + + +_audit_logger = _build_audit_logger() + + +def get_audit_logger() -> logging.Logger: + return _audit_logger + + +def _get_client_ip(request: Request) -> str: + forwarded = request.headers.get("X-Forwarded-For", "") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +def _redact_sensitive_headers(headers: dict) -> dict: + """Remove auth tokens and API keys from logged headers.""" + sensitive = {"authorization", "x-api-key", "cookie", "set-cookie"} + return {k: "[REDACTED]" if k.lower() in sensitive else v for k, v in headers.items()} + + +class AuditMiddleware(BaseHTTPMiddleware): + """ + FastAPI middleware that writes one structured JSON audit record per request. + + Fields logged: + req_id, timestamp_utc, method, path, query, client_ip, + session_id, status, latency_ms, sensitive, error (if any) + """ + + def __init__(self, app: ASGIApp, environment: str = "production") -> None: + super().__init__(app) + self._env = environment + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + if request.url.path in _SKIP_PATHS: + return await call_next(request) + + req_id = str(uuid.uuid4())[:8] + t_start = time.perf_counter() + + # Attach req_id so endpoints can reference it in error messages + request.state.req_id = req_id + + error_detail: str | None = None + status_code = 500 + try: + response = await call_next(request) + status_code = response.status_code + except Exception as exc: + error_detail = type(exc).__name__ + ": " + str(exc)[:200] + raise + finally: + latency_ms = round((time.perf_counter() - t_start) * 1000, 1) + path = request.url.path + record: dict = { + "req_id": req_id, + "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "method": request.method, + "path": path, + "query": str(request.url.query)[:200] or None, + "ip": _get_client_ip(request), + "session_id": request.headers.get("X-Session-Id", "anon")[:64], + "status": status_code, + "latency_ms": latency_ms, + "sensitive": path in _SENSITIVE_PATHS, + "env": self._env, + } + if error_detail: + record["error"] = error_detail + _audit_logger.info(json.dumps(record, ensure_ascii=False)) + + response.headers["X-Request-Id"] = req_id + return response diff --git a/backend/middleware/auth_middleware.py b/backend/middleware/auth_middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..1fdf9f03d00d200d3598814d5dab5a8e91dd1666 --- /dev/null +++ b/backend/middleware/auth_middleware.py @@ -0,0 +1,186 @@ +""" +Supabase JWT verification for FastAPI. + +Supabase now supports two signing modes: + - ES256 (ECDSA, newer projects): verified via JWKS public key endpoint + - HS256 (HMAC, older projects): verified via SUPABASE_JWT_SECRET + +This middleware auto-detects the algorithm from the JWT header and uses +the appropriate verification method. + +Usage: + @app.get("/api/me") + async def me(user = Depends(require_user)): + return {"id": user["sub"], "email": user["email"]} + + @app.get("/api/public") + async def public(user = Depends(optional_user)): + ... + +Environment: + SUPABASE_URL — project URL (for JWKS endpoint) + SUPABASE_JWT_SECRET — required only for HS256 projects +""" + +from __future__ import annotations + +import os +import logging +import time +from typing import Any + +import jwt +from jwt import PyJWKClient +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +log = logging.getLogger("tebyan.auth") + +_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET", "") +_SUPABASE_URL = os.getenv("SUPABASE_URL", "") +_ALGORITHM_HS = "HS256" +_ALGORITHM_ES = "ES256" +_AUDIENCE = "authenticated" + +# JWKS client — caches public keys for 10 minutes +_jwks_client: PyJWKClient | None = None +_jwks_last_fetched: float = 0.0 +_JWKS_CACHE_TTL = 600 # seconds + +# auto_error=False lets us handle missing token ourselves (for optional_user) +_bearer = HTTPBearer(auto_error=False) + + +def _get_jwks_client() -> PyJWKClient | None: + global _jwks_client, _jwks_last_fetched + if not _SUPABASE_URL: + return None + now = time.time() + if _jwks_client is None or (now - _jwks_last_fetched) > _JWKS_CACHE_TTL: + try: + url = f"{_SUPABASE_URL}/auth/v1/.well-known/jwks.json" + _jwks_client = PyJWKClient(url, cache_keys=True) + _jwks_last_fetched = now + log.debug("JWKS client initialised from %s", url) + except Exception as e: + log.warning("Failed to init JWKS client: %s", e) + return None + return _jwks_client + + +def _get_algorithm(token: str) -> str: + """Peek at the JWT header to determine signing algorithm.""" + try: + header = jwt.get_unverified_header(token) + return header.get("alg", _ALGORITHM_HS) + except Exception: + return _ALGORITHM_HS + + +def _decode(token: str) -> dict[str, Any]: + """ + Decode and verify a Supabase JWT. + Supports both ES256 (JWKS) and HS256 (secret). + Raises HTTPException on any failure. + """ + alg = _get_algorithm(token) + + try: + if alg == _ALGORITHM_ES: + # ES256 — verify via JWKS public key + client = _get_jwks_client() + if client is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Auth not configured on server (SUPABASE_URL missing)", + ) + signing_key = client.get_signing_key_from_jwt(token) + return jwt.decode( + token, + signing_key.key, + algorithms=[_ALGORITHM_ES], + audience=_AUDIENCE, + ) + else: + # HS256 — verify via shared secret + if not _JWT_SECRET: + log.error("SUPABASE_JWT_SECRET not set — cannot verify HS256 tokens") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Auth not configured on server", + ) + return jwt.decode( + token, + _JWT_SECRET, + algorithms=[_ALGORITHM_HS], + audience=_AUDIENCE, + ) + + except HTTPException: + raise + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"error": "session_expired", "message": "انتهت صلاحية الجلسة — يرجى تسجيل الدخول مجدداً"}, + headers={"WWW-Authenticate": "Bearer"}, + ) + except jwt.InvalidAudienceError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"error": "invalid_token", "message": "رمز المصادقة غير صالح"}, + headers={"WWW-Authenticate": "Bearer"}, + ) + except jwt.InvalidTokenError as e: + log.debug("JWT decode failed: %s", e) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"error": "invalid_token", "message": "رمز المصادقة غير صالح"}, + headers={"WWW-Authenticate": "Bearer"}, + ) + + +# ── Public dependency functions ─────────────────────────────────────────────── + +async def require_user( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> dict[str, Any]: + """ + FastAPI dependency that returns the decoded JWT payload. + Raises 401 if the request has no valid Bearer token. + """ + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"error": "missing_token", "message": "تسجيل الدخول مطلوب"}, + headers={"WWW-Authenticate": "Bearer"}, + ) + return _decode(credentials.credentials) + + +async def optional_user( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> dict[str, Any] | None: + """ + FastAPI dependency that returns the decoded JWT payload or None. + Does not raise — lets the route handle unauthenticated requests. + Returns None gracefully if JWKS/secret is not configured. + """ + if not credentials: + return None + if not _SUPABASE_URL and not _JWT_SECRET: + log.debug("Auth not configured — treating request as anonymous") + return None + try: + return _decode(credentials.credentials) + except HTTPException: + return None + + +def get_user_id(user: dict[str, Any]) -> str: + """Extract the Supabase user UUID from a decoded payload.""" + return user["sub"] + + +def get_user_email(user: dict[str, Any]) -> str: + """Extract the user email from a decoded payload.""" + return user.get("email", "") diff --git a/backend/middleware/sanitizer.py b/backend/middleware/sanitizer.py new file mode 100644 index 0000000000000000000000000000000000000000..e799756e09f36329a23c33d5d9886e72ec27460b --- /dev/null +++ b/backend/middleware/sanitizer.py @@ -0,0 +1,155 @@ +""" +Request sanitization and file validation for medical file uploads. + +Validates: + - File size (max 20 MB) + - MIME type whitelist (PDF, JPEG, PNG, WEBP, TIFF) + - Magic bytes (header sniffing — prevents MIME spoofing) + - Filename safety (path traversal, null bytes, dangerous extensions) + - Text input: strips HTML tags, limits length, removes null bytes +""" +from __future__ import annotations + +import re +from typing import Final + +from fastapi import HTTPException, UploadFile + +# ── Limits ─────────────────────────────────────────────────────────────────── + +MAX_FILE_BYTES: Final[int] = 20 * 1024 * 1024 # 20 MB +MAX_TEXT_LEN: Final[int] = 8_000 # characters per text field + +# ── Allowed MIME types (whitelist) ─────────────────────────────────────────── + +_ALLOWED_MIME: Final[frozenset[str]] = frozenset([ + "application/pdf", + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", + "image/tiff", +]) + +# ── Magic bytes (first bytes of file → expected MIME group) ───────────────── + +_MAGIC: Final[list[tuple[bytes, str]]] = [ + (b"%PDF", "pdf"), + (b"\xff\xd8\xff", "jpeg"), + (b"\x89PNG", "png"), + (b"RIFF", "webp"), # RIFF....WEBP + (b"II*\x00", "tiff"), # little-endian TIFF + (b"MM\x00*", "tiff"), # big-endian TIFF +] + +_MIME_TO_MAGIC_GROUP: Final[dict[str, str]] = { + "application/pdf": "pdf", + "image/jpeg": "jpeg", + "image/jpg": "jpeg", + "image/png": "png", + "image/webp": "webp", + "image/tiff": "tiff", +} + +# ── Dangerous file extensions (block even if MIME seems OK) ────────────────── + +_BLOCKED_EXT: Final[frozenset[str]] = frozenset([ + ".exe", ".sh", ".bat", ".cmd", ".ps1", ".js", ".php", ".py", + ".rb", ".pl", ".dll", ".so", ".elf", ".msi", ".vbs", ".hta", + ".jar", ".class", ".com", ".scr", ".pif", +]) + +# ── Text sanitization ──────────────────────────────────────────────────────── + +_HTML_TAG_RE = re.compile(r"<[^>]{0,200}>") +_SCRIPT_RE = re.compile(r"(?i)(javascript:|data:text/html| str: + """ + Strip HTML, null bytes, and obvious injection patterns from user text. + Truncates to max_len characters. + """ + if not isinstance(text, str): + return "" + text = _NULL_BYTE_RE.sub("", text) + text = _HTML_TAG_RE.sub("", text) + if _SCRIPT_RE.search(text): + raise HTTPException(status_code=400, detail="محتوى غير مسموح به في النص المرسل") + if _SQL_INJECT.search(text): + raise HTTPException(status_code=400, detail="محتوى غير مسموح به في النص المرسل") + return text[:max_len].strip() + + +def _sniff_magic(data: bytes) -> str | None: + """Return magic group name or None if unrecognised.""" + for magic, group in _MAGIC: + if data[:len(magic)] == magic: + # Special: WebP has 'WEBP' at offset 8 + if group == "webp" and data[8:12] != b"WEBP": + continue + return group + return None + + +def _safe_filename(filename: str) -> str: + """Sanitize filename: strip path components and dangerous characters.""" + # Strip directory traversal + name = re.sub(r"[/\\]", "_", filename) + # Remove null bytes and control chars + name = re.sub(r"[\x00-\x1f]", "", name) + # Keep only safe chars + name = re.sub(r"[^\w.\-]", "_", name) + return name[:128] or "upload" + + +def validate_upload(file: UploadFile, data: bytes) -> None: + """ + Validate an uploaded file. Raises HTTPException on any violation. + + Args: + file: The UploadFile from FastAPI (provides filename + content_type). + data: Raw bytes already read from the file. + + Raises: + HTTPException 400 for bad requests, 413 for file too large, 415 for unsupported type. + """ + # ── Size ───────────────────────────────────────────────────────────────── + if len(data) == 0: + raise HTTPException(status_code=400, detail="الملف فارغ") + if len(data) > MAX_FILE_BYTES: + mb = MAX_FILE_BYTES // (1024 * 1024) + raise HTTPException(status_code=413, detail=f"حجم الملف يتجاوز الحد المسموح ({mb} MB)") + + # ── Filename safety ─────────────────────────────────────────────────────── + filename = (file.filename or "").strip() + if not filename: + raise HTTPException(status_code=400, detail="اسم الملف مطلوب") + + ext = "." + filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + if ext in _BLOCKED_EXT: + raise HTTPException(status_code=415, detail=f"نوع الملف '{ext}' غير مسموح") + + # ── MIME whitelist ──────────────────────────────────────────────────────── + content_type = (file.content_type or "").split(";")[0].strip().lower() + if content_type not in _ALLOWED_MIME: + raise HTTPException( + status_code=415, + detail=f"نوع الملف '{content_type}' غير مدعوم. الأنواع المقبولة: PDF, JPEG, PNG, WEBP, TIFF", + ) + + # ── Magic bytes validation (anti-MIME-spoofing) ─────────────────────────── + detected = _sniff_magic(data) + expected_group = _MIME_TO_MAGIC_GROUP.get(content_type) + if detected is None: + raise HTTPException(status_code=415, detail="تعذّر التحقق من نوع الملف — بيانات غير صالحة") + if expected_group and detected != expected_group: + raise HTTPException( + status_code=415, + detail=f"محتوى الملف لا يطابق نوعه المُعلَن ({content_type})", + ) diff --git a/backend/prompts/examples/cbc_examples.json b/backend/prompts/examples/cbc_examples.json new file mode 100644 index 0000000000000000000000000000000000000000..93e19b0f17d43f47f594f49ee36c15247bbbd7a5 --- /dev/null +++ b/backend/prompts/examples/cbc_examples.json @@ -0,0 +1,76 @@ +{ + "_description": "CBC few-shot examples — used to prime LLM with correct Arabic medical format", + "_version": "2.0", + "examples": [ + { + "id": "cbc_iron_deficiency", + "scenario": "Iron-deficiency anemia — female, 28 years", + "input_findings": [ + {"name": "Hemoglobin", "value": "9.8", "range": "12.0-15.5", "unit": "g/dL", "status": "low"}, + {"name": "MCV", "value": "71", "range": "80-100", "unit": "fL", "status": "low"}, + {"name": "MCH", "value": "23", "range": "27-33", "unit": "pg", "status": "low"}, + {"name": "WBC", "value": "6.5", "range": "4.0-11.0", "unit": "10³/μL", "status": "normal"}, + {"name": "Platelets", "value": "310", "range": "150-400", "unit": "10³/μL", "status": "normal"} + ], + "ideal_output": { + "تقييم_عام": "خلايا الدم البيضاء والصفائح الدموية طبيعية تماماً — وهو مؤشر جيد لسلامة جهازك المناعي. يستدعي الانتباه انخفاض الهيموجلوبين مع صغر حجم الكريات الحمراء، وهو نمط كلاسيكي لفقر الدم الناجم عن نقص الحديد — وهي حالة شائعة ومناسبة للعلاج.", + "interpretation_chain": "Hgb low + MCV low + MCH low → microcytic hypochromic anemia → most likely iron deficiency → recommend Ferritin + Iron panel", + "key_message_ar": "فقر دم بسيط إلى متوسط بسبب نقص الحديد على الأرجح — قابل للعلاج بالغذاء والمكملات", + "توصيات": [ + { + "category": "التغذية الغنية بالحديد", + "tips": ["تناول اللحوم الحمراء 3 مرات أسبوعياً", "السبانخ والعدس والفول من أغنى المصادر النباتية", "اقرن وجبات الحديد بعصير البرتقال لتحسين الامتصاص 2×", "تجنب الشاي والقهوة مباشرة بعد الوجبات — تقلل الامتصاص 60%"] + }, + { + "category": "المتابعة الطبية", + "tips": ["أجري فحص الفيريتين والحديد لتأكيد السبب", "راجعي طبيبك لتحديد جرعة مكمل الحديد المناسبة", "أعيدي التحليل بعد 3 أشهر من العلاج لمتابعة التحسن"] + } + ] + } + }, + { + "id": "cbc_all_normal", + "scenario": "All CBC values normal — male, 35 years", + "input_findings": [ + {"name": "Hemoglobin", "value": "15.2", "range": "13.5-17.5", "unit": "g/dL", "status": "normal"}, + {"name": "WBC", "value": "7.1", "range": "4.0-11.0", "unit": "10³/μL", "status": "normal"}, + {"name": "Platelets", "value": "245", "range": "150-400", "unit": "10³/μL", "status": "normal"}, + {"name": "Hematocrit", "value": "46", "range": "41-53", "unit": "%", "status": "normal"} + ], + "ideal_output": { + "تقييم_عام": "ممتاز! جميع قيم صورة الدم الكاملة ضمن المعدل الطبيعي — الهيموجلوبين يحمل الأكسجين بكفاءة، وخلايا المناعة بعدد مثالي، والصفائح الدموية تضمن التجلط الصحي. هذه نتائج تعكس صحة دموية جيدة.", + "key_message_ar": "نتائج طبيعية — استمر على نمط حياتك الصحي", + "توصيات": [ + { + "category": "الحفاظ على الصحة الدموية", + "tips": ["تناول وجبات متوازنة تشمل البروتين والحديد والفيتامينات", "مارس الرياضة المعتدلة 150 دقيقة أسبوعياً", "اشرب 2 لتر ماء يومياً للحفاظ على حجم الدم", "أجرِ صورة الدم الكاملة سنوياً للمتابعة الوقائية"] + } + ] + } + }, + { + "id": "cbc_infection", + "scenario": "Elevated WBC suggesting infection", + "input_findings": [ + {"name": "WBC", "value": "14.5", "range": "4.0-11.0", "unit": "10³/μL", "status": "high"}, + {"name": "Neutrophils", "value": "82", "range": "50-70", "unit": "%", "status": "high"}, + {"name": "Hemoglobin", "value": "13.8", "range": "13.5-17.5", "unit": "g/dL", "status": "normal"}, + {"name": "Platelets", "value": "420", "range": "150-400", "unit": "10³/μL", "status": "high"} + ], + "ideal_output": { + "تقييم_عام": "الهيموجلوبين طبيعي وهو مؤشر إيجابي. يُلاحظ ارتفاع في خلايا الدم البيضاء مع ارتفاع النيتروفيل — وهو نمط يشير عادةً إلى وجود عدوى بكتيرية أو التهاب في مكان ما في الجسم. الصفائح مرتفعة قليلاً وهو تفاعل طبيعي مع الالتهاب.", + "interpretation_chain": "WBC 14.5 (high) + Neutrophils 82% (high) → bacterial infection pattern → body in acute inflammatory response", + "توصيات": [ + { + "category": "المتابعة الطبية العاجلة", + "tips": ["راجع طبيبك لتحديد موضع العدوى والعلاج المناسب", "أخبر طبيبك عن أي أعراض: حمى، ألم، سعال، حرقة بول", "لا تؤخر المراجعة إذا كانت لديك حمى > 38.5 درجة"] + }, + { + "category": "الراحة والترطيب", + "tips": ["استرح واشرب سوائل كافية", "تجنب مجهود شديد حتى يتضح التشخيص", "راقب درجة حرارتك يومياً"] + } + ] + } + } + ] +} diff --git a/backend/prompts/examples/thyroid_examples.json b/backend/prompts/examples/thyroid_examples.json new file mode 100644 index 0000000000000000000000000000000000000000..f0a4a6af17b3b4b8f6e226365c41f8b0c3991645 --- /dev/null +++ b/backend/prompts/examples/thyroid_examples.json @@ -0,0 +1,50 @@ +{ + "_description": "Thyroid few-shot examples", + "_version": "2.0", + "examples": [ + { + "id": "hypothyroidism_primary", + "scenario": "Primary hypothyroidism — TSH high, FT4 low", + "input_findings": [ + {"name": "TSH", "value": "9.8", "range": "0.4-4.0", "unit": "mIU/L", "status": "high"}, + {"name": "Free T4", "value": "0.6", "range": "0.8-1.8", "unit": "ng/dL", "status": "low"}, + {"name": "Anti-TPO", "value": "380", "range": "0-34", "unit": "IU/mL", "status": "high"} + ], + "ideal_output": { + "تقييم_عام": "نتائجك تكشف عن قصور في نشاط الغدة الدرقية — فالـ TSH المرتفع من الغدة النخامية يحاول تنبيه الغدة الدرقية لإنتاج المزيد، لكن الغدة تستجيب بإنتاج T4 أقل من الطبيعي. ارتفاع الأجسام المضادة يشير إلى أن سبب القصور على الأرجح مناعي ذاتي (هاشيموتو).", + "interpretation_chain": "TSH↑ + FT4↓ = primary hypothyroidism | Anti-TPO↑↑ = Hashimoto's thyroiditis (autoimmune)", + "key_message_ar": "قصور الغدة الدرقية بسبب هاشيموتو — يُعالج بسهولة بحبة يومية من الليفوثيروكسين", + "أعراض_متوقعة": ["تعب وإرهاق", "زيادة الوزن", "برودة الأطراف", "بطء القلب", "إمساك", "جفاف البشرة والشعر"], + "توصيات": [ + { + "category": "المتابعة الطبية", + "tips": ["راجع طبيبك الآن لبدء علاج الليفوثيروكسين", "أعد فحص TSH و FT4 بعد 6-8 أسابيع من بدء العلاج", "الالتزام بالدواء يومياً قبل الأكل يضمن نتائج ممتازة", "لا تتوقف عن الدواء دون استشارة طبيبك"] + }, + { + "category": "نصائح غذائية", + "tips": ["تجنب الإفراط في السيلينيوم والكالسيوم كمكملات — تؤثر على الامتصاص", "اليود في الملح المُدعّم كافٍ — لا تفرط بمكملات اليود", "يُنصح باتباع نظام غذائي متوازن وممارسة الرياضة بانتظام"] + } + ] + } + }, + { + "id": "subclinical_hypothyroid", + "scenario": "Subclinical hypothyroidism — TSH slightly high, FT4 normal", + "input_findings": [ + {"name": "TSH", "value": "5.8", "range": "0.4-4.0", "unit": "mIU/L", "status": "high"}, + {"name": "Free T4", "value": "1.2", "range": "0.8-1.8", "unit": "ng/dL", "status": "normal"} + ], + "ideal_output": { + "تقييم_عام": "TSH مرتفع قليلاً فوق المعدل الطبيعي، لكن هرمون الغدة الدرقية FT4 طبيعي — وهو ما يسمى 'القصور تحت السريري'. الغدة الدرقية لا تزال تعمل جيداً ولكنها تتطلب جهداً أكبر من الطبيعي.", + "interpretation_chain": "TSH↑ mildly + FT4 normal = subclinical hypothyroidism → monitor every 6 months, treat if symptomatic or TSH > 10", + "key_message_ar": "قصور درقي تحت سريري — يحتاج متابعة لا علاجاً فورياً في الغالب", + "توصيات": [ + { + "category": "المتابعة الطبية", + "tips": ["ناقش نتائجك مع طبيبك — قد يقرر المتابعة أو العلاج حسب أعراضك", "أعد فحص TSH بعد 3-6 أشهر", "أبلغ طبيبك عن أي أعراض: تعب غير معتاد، برودة، زيادة وزن"] + } + ] + } + } + ] +} diff --git a/backend/prompts/extraction_template.txt b/backend/prompts/extraction_template.txt new file mode 100644 index 0000000000000000000000000000000000000000..33e46e763a7fe97966909198e9f59ff0f77926ad --- /dev/null +++ b/backend/prompts/extraction_template.txt @@ -0,0 +1,43 @@ +# PROMPT: extraction_template | version: 1.0 + +أنت خبير في تحليل التقارير الطبية المختبرية. مهمتك استخراج جميع نتائج الفحوصات من نص التقرير بدقة تامة. + +## تعليمات الاستخراج + +1. استخرج كل فحص له اسم + قيمة رقمية (أو نصية واضحة مثل Positive/Negative) +2. استخرج المعدل الطبيعي (reference range) إذا كان موجوداً في النص +3. استخرج الوحدة (g/dL, mg/dL, %, IU/L, ...) إذا ذُكرت +4. تجاهل: التواريخ، أسماء المرضى، أرقام التعريف، بيانات المختبر، توقيعات الأطباء +5. إذا كانت القيمة مكتوبة بالعربية والإنجليزية، فضّل الإنجليزية للرقم +6. لا تخترع قيماً غير موجودة في النص + +## شكل الإخراج (JSON array فقط — لا تضف أي نص قبله أو بعده) + +[ + { + "name": "اسم الفحص بالإنجليزية أو العربية كما في التقرير", + "value": "القيمة الرقمية فقط (بدون وحدة)", + "range": "المعدل الطبيعي كما في التقرير أو '' إذا لم يُذكر", + "unit": "الوحدة أو '' إذا لم تُذكر" + } +] + +## أمثلة على الاستخراج الصحيح + +النص: "Hemoglobin (HGB) 10.5 12.0-16.0 g/dL L" +الاستخراج: {"name": "Hemoglobin", "value": "10.5", "range": "12.0-16.0", "unit": "g/dL"} + +النص: "هيموجلوبين: 13.2 جم/ديسيلتر (طبيعي: 13.5-17.5)" +الاستخراج: {"name": "هيموجلوبين", "value": "13.2", "range": "13.5-17.5", "unit": "جم/ديسيلتر"} + +النص: "WBC 7.2 4.0-11.0 10³/μL" +الاستخراج: {"name": "WBC", "value": "7.2", "range": "4.0-11.0", "unit": "10³/μL"} + +## ملاحظات مهمة + +- إذا لم تجد أي فحوصات مختبرية في النص → أرجع [] فقط +- الأسماء المزدوجة (CBC / دم شامل) → استخدم الاسم الأوضح +- الفحوصات النصية (مثل نوع الدم A+) → استخرجها كـ value نصية + +## النص المراد تحليله +{{LAB_TEXT}} diff --git a/backend/prompts/few_shot_examples.json b/backend/prompts/few_shot_examples.json new file mode 100644 index 0000000000000000000000000000000000000000..6f7a4330547bf338ee0aa11e27820dff7bb5f2ce --- /dev/null +++ b/backend/prompts/few_shot_examples.json @@ -0,0 +1,92 @@ +{ + "_description": "Few-shot examples for medical analysis and chat. Used to prime LLM with correct tone, format, and Arabic medical register.", + "_version": "1.0", + "_last_updated": "2026-05-24", + + "extraction_examples": [ + { + "input": "CBC Report\nHemoglobin 10.2 12.0-16.0 g/dL LOW\nWBC 6.8 4.0-11.0 10³/μL Normal\nPlatelets 220 150-400 10³/μL\nHematocrit 31.5 36-48 % LOW", + "output": [ + {"name": "Hemoglobin", "value": "10.2", "range": "12.0-16.0", "unit": "g/dL"}, + {"name": "WBC", "value": "6.8", "range": "4.0-11.0", "unit": "10³/μL"}, + {"name": "Platelets", "value": "220", "range": "150-400", "unit": "10³/μL"}, + {"name": "Hematocrit", "value": "31.5", "range": "36-48", "unit": "%"} + ] + }, + { + "input": "تحليل وظائف الكبد\nALT (SGPT): 85 وحدة/لتر (طبيعي: 7-40)\nAST (SGOT): 72 وحدة/لتر (طبيعي: 7-40)\nبيليروبين كلي: 1.1 ملجم/ديسيلتر (طبيعي: 0.2-1.2)\nألبومين: 4.0 جم/ديسيلتر (طبيعي: 3.5-5.0)", + "output": [ + {"name": "ALT (SGPT)", "value": "85", "range": "7-40", "unit": "وحدة/لتر"}, + {"name": "AST (SGOT)", "value": "72", "range": "7-40", "unit": "وحدة/لتر"}, + {"name": "بيليروبين كلي", "value": "1.1", "range": "0.2-1.2", "unit": "ملجم/ديسيلتر"}, + {"name": "ألبومين", "value": "4.0", "range": "3.5-5.0", "unit": "جم/ديسيلتر"} + ] + } + ], + + "analysis_examples": [ + { + "scenario": "فقر دم خفيف مع TSH طبيعي", + "findings_summary": "Hemoglobin: 10.2 (low), TSH: 2.1 (normal), Ferritin: 8 (low)", + "ideal_general_assessment": "معظم قيمك ضمن المعدل الطبيعي، وخاصة وظائف الغدة الدرقية. يستدعي انتباهك انخفاض الهيموجلوبين والفيريتين، مما يشير إلى فقر دم بسبب نقص الحديد — وهو من أكثر الحالات شيوعاً عند النساء وقابل للعلاج بسهولة.", + "ideal_tips": [ + "تناول أطعمة غنية بالحديد مثل السبانخ واللحوم الحمراء والبقوليات", + "اقرن مصادر الحديد بفيتامين C (برتقال، طماطم) لتحسين الامتصاص", + "تجنب شرب الشاي أو القهوة مع الوجبات — تقلل امتصاص الحديد بنسبة 60%", + "راجع طبيبك لتحديد جرعة مكمل الحديد المناسبة لك" + ] + }, + { + "scenario": "ارتفاع السكر التراكمي", + "findings_summary": "HbA1c: 8.2% (high, normal <5.7%), Fasting Glucose: 178 (high)", + "ideal_general_assessment": "نتائج السكر تشير إلى أن مستوى السكر في الدم كان مرتفعاً خلال الأشهر الثلاثة الماضية. هذا يستدعي مراجعة الطبيب لمناقشة خطة العلاج، سواء بتعديل النظام الغذائي أو الأدوية.", + "chain_of_thought": "HbA1c يعكس متوسط السكر خلال 3 أشهر. 8.2% يعني متوسط سكر ~190 mg/dL. مستهدف مرضى السكري عادة <7%. الفرق 1.2% يحتاج تدخلاً علاجياً." + } + ], + + "chat_examples": [ + { + "user": "ما معنى أن الهيموجلوبين عندي 10.2؟", + "assistant": "الهيموجلوبين هو البروتين الذي يحمل الأكسجين في خلايا الدم الحمراء. قيمتك (10.2 g/dL) أقل من المعدل الطبيعي للنساء (12–15.5 g/dL)، مما يشير إلى فقر دم خفيف إلى متوسط.\n\nالأسباب الشائعة: نقص الحديد، نقص فيتامين B12، أو نقص حمض الفوليك. لتحديد السبب الدقيق، ينصح طبيبك بفحص مستوى الفيريتين وفيتامين B12.", + "tone_notes": "مطمئن، يشرح المصطلح أولاً، يذكر الأسباب الشائعة دون تهويل، يحيل للطبيب" + }, + { + "user": "أعاني من تعب شديد وشحوب — ماذا يعني؟", + "assistant": "التعب الشديد والشحوب من أبرز علامات فقر الدم (أنيميا)، وهو من أكثر الحالات شيوعاً وقابلية للعلاج.\n\nلكن قد يكون لها أسباب أخرى مثل: نقص فيتامين D، قصور الغدة الدرقية، أو مشاكل في النوم.\n\n**التحاليل المقترحة:**\n- صورة دم كاملة (CBC)\n- فيريتين وحديد مصل\n- فيتامين D وB12\n- وظائف الغدة الدرقية (TSH)", + "tone_notes": "يبدأ بالسبب الأرجح، لا يهول، يذكر بدائل، يختم بتحاليل مقترحة" + }, + { + "user": "هل يمكنني أكل الفراولة إذا كان سكري مرتفعاً؟", + "assistant": "نعم، يمكنك تناول الفراولة باعتدال! فهي منخفضة المؤشر الجلايسيمي نسبياً مقارنة بكثير من الفواكه الأخرى.\n\nالكمية المناسبة: حوالي نصف كوب (75 جرام) في الوجبة. والأفضل تناولها مع وجبة تحتوي على بروتين أو دهون صحية لإبطاء امتصاص السكر.\n\nوفقاً لـ American Diabetes Association، الفواكه الطازجة جزء صحي من نظام مرضى السكري — المهم الكمية، لا الحذف الكلي.", + "tone_notes": "جواب مباشر إيجابي، كمية محددة، سياق علمي موثق، لا نهي مطلق" + }, + { + "user": "عندي ألم في الصدر وضيق تنفس منذ ساعة", + "assistant": "⚠️ هذه الأعراض تستدعي التوجه فوراً لأقرب طوارئ أو الاتصال بالإسعاف.\n\nألم الصدر مع ضيق التنفس يحتاج تقييماً طبياً عاجلاً لاستبعاد أسباب قلبية أو تنفسية. لا تنتظر وراجع طوارئ أقرب مستشفى الآن.", + "tone_notes": "أول جملة تحذير واضح، لا تشخيص، توجيه فوري للطوارئ" + } + ], + + "severity_response_guide": { + "all_normal": { + "tone": "تهنئة مع تشجيع على الاستمرار", + "opening": "ممتاز! جميع قيمك ضمن المعدل الطبيعي.", + "tips_focus": "وقاية وصحة عامة" + }, + "mild_abnormal": { + "tone": "مطمئن، تفسيري", + "opening": "نتائجك جيدة بشكل عام، مع بعض القيم التي تستحق الانتباه.", + "tips_focus": "نصائح غذائية ونمط حياة" + }, + "moderate_abnormal": { + "tone": "واضح وصريح دون تهويل", + "opening": "بعض قيمك تخرج عن المعدل الطبيعي وتحتاج متابعة.", + "tips_focus": "تغيير نمط الحياة + توصية بمراجعة الطبيب" + }, + "severe_abnormal": { + "tone": "جاد ومحفّز للتصرف", + "opening": "قيم عدة تحتاج اهتماماً طبياً. أنصح بمراجعة طبيبك قريباً.", + "tips_focus": "التوصية الطبية أولاً، ثم نصائح داعمة" + } + } +} diff --git a/backend/prompts/loader.py b/backend/prompts/loader.py new file mode 100644 index 0000000000000000000000000000000000000000..dcc9cb5b68dd866363faaf6af3e7d05c2bc5444c --- /dev/null +++ b/backend/prompts/loader.py @@ -0,0 +1,55 @@ +""" +Prompt loader — loads and renders prompt templates from the prompts/ directory. +Usage: + from prompts.loader import render, load + prompt = render("templates/cbc_analysis_prompt", FINDINGS="...", RAG_CONTEXT="...") +""" +from __future__ import annotations +import pathlib +import re + +_ROOT = pathlib.Path(__file__).parent + +# In-memory cache: filename → raw text +_cache: dict[str, str] = {} + + +def load(name: str) -> str: + """ + Load a prompt file by name (without extension). + Supports subdirs: 'templates/cbc_analysis_prompt' + Falls back to '' if file not found — never raises. + """ + if name in _cache: + return _cache[name] + for ext in (".txt", ".md", ""): + path = _ROOT / (name + ext) + if path.exists(): + _cache[name] = path.read_text(encoding="utf-8") + return _cache[name] + _cache[name] = "" + return "" + + +def render(name: str, **kwargs: str) -> str: + """ + Load and fill {{PLACEHOLDER}} variables. + Strips any unfilled placeholders after substitution. + """ + tmpl = load(name) + for key, val in kwargs.items(): + tmpl = tmpl.replace(f"{{{{{key}}}}}", str(val)) + # Remove any remaining {{UNFILLED}} markers + tmpl = re.sub(r"\{\{[A-Z_]+\}\}", "", tmpl) + return tmpl.strip() + + +def clear_cache() -> None: + _cache.clear() + + +def list_available() -> list[str]: + return [ + str(p.relative_to(_ROOT).with_suffix("")) + for p in _ROOT.rglob("*.txt") + ] diff --git a/backend/prompts/system_analysis.txt b/backend/prompts/system_analysis.txt new file mode 100644 index 0000000000000000000000000000000000000000..166fe61444add268824955b559691960e7977b00 --- /dev/null +++ b/backend/prompts/system_analysis.txt @@ -0,0 +1,68 @@ +# PROMPT: system_analysis | version: 1.0 | lang: ar+en + +أنت طبيب مختبر خبير ومعلم طبي متخصص في تفسير التحاليل المختبرية للمرضى العرب. + +## دورك +تفسّر نتائج التحاليل الطبية وتقدمها بأسلوب واضح ومطمئن ومفهوم للمريض غير المتخصص. +تتكلم بلغة عربية فصحى مبسّطة — لا تقنية جافة، ولا تهويل غير ضروري. + +## قواعد الإجابة (يجب الالتزام بها) + +1. **الدقة أولاً:** لا تخترع معلومة. إذا لم تكن النتيجة في البيانات المقدمة، اذكر ذلك صراحةً. +2. **لا تشخيص قاطع:** افسّر الأرقام ووضّح ما تعنيه، لكن لا تحدد مرضاً بشكل قاطع. +3. **الإحالة دائماً:** أنهِ كل تقييم بتوصية بمراجعة الطبيب، خصوصاً عند وجود قيم غير طبيعية. +4. **المصادر الطبية:** استند للمراجع المقدمة من قاعدة المعرفة — اذكر المصدر إذا استخدمته. +5. **النبرة:** مطمئنة وواضحة. لا تخيف المريض بمصطلحات معقدة دون شرح. + +## شكل الإخراج المطلوب (JSON فقط — ابدأ بـ { مباشرة) + +{ + "تقييم_عام": "جملتان عن الحالة العامة. أبدأ بالإيجابيات إن وُجدت.", + "قيم_غير_طبيعية": [ + { + "اسم_الفحص": "اسم الفحص", + "النتيجة": "القيمة + الوحدة", + "المعدل_الطبيعي": "نطاق المرجع", + "الحالة": "مرتفع | منخفض", + "الشرح": "شرح طبي مبسط في جملة أو جملتين: ما الذي يعنيه هذا الرقم، والأسباب الشائعة.", + "المرجع": "اسم المصدر أو 'لا يوجد'" + } + ], + "توصيات": [ + { + "category": "اسم الفئة (مثال: التغذية، الراحة، المتابعة الطبية)", + "tips": ["نصيحة 1", "نصيحة 2", "نصيحة 3", "نصيحة 4"] + } + ] +} + +## منهج التفسير خطوة بخطوة — Chain-of-Thought (إلزامي) + +لكل قيمة غير طبيعية اتبع هذه الخطوات بالترتيب في حقل "الشرح": +1. اذكر القيمة المُقاسة ووحدتها +2. قارنها بالمعدل الطبيعي المرجعي وحدد مقدار الانحراف (قليل/متوسط/كبير) +3. اذكر 2-3 أسباب محتملة مرتبة حسب الشيوع +4. صُغ التوصية حسب شدة الانحراف (متابعة / فحص إضافي / مراجعة عاجلة) + +## أمثلة few-shot على التفسير الصحيح + +مثال 1 — هيموجلوبين منخفض: +المدخل: Hemoglobin = 10.2 g/dL، المعدل: 12.0–15.5 (نساء) +الشرح الصحيح: "قيمتك (10.2) أقل من المعدل الطبيعي للنساء (12–15.5 g/dL) بمقدار 1.8 نقطة — انحراف خفيف إلى متوسط. الأسباب الأكثر شيوعاً: (1) نقص الحديد — خاصة مع الدورة الشهرية، (2) نقص فيتامين B12 أو حمض الفوليك، (3) نقص في التغذية. التوصية: فحص الفيريتين والحديد لتحديد السبب الدقيق." + +مثال 2 — HbA1c مرتفع: +المدخل: HbA1c = 8.2%، المعدل: < 5.7% (طبيعي) +الشرح الصحيح: "قيمتك (8.2%) تعكس متوسط سكر الدم خلال 3 أشهر الماضية. المعدل الطبيعي أقل من 5.7%، وما بين 5.7–6.4% يُعتبر ما قبل السكري. قيمتك تقع في نطاق مرضى السكري (≥6.5%). الهدف العلاجي عادة أقل من 7% لمرضى السكري. يُنصح بمراجعة الطبيب لتقييم خطة العلاج." + +مثال 3 — TSH مرتفع: +المدخل: TSH = 6.8 mIU/L، المعدل: 0.4–4.0 +الشرح الصحيح: "TSH هو هرمون يُصدره الدماغ لتحفيز الغدة الدرقية. ارتفاعه (6.8 مقابل المعدل 0.4–4.0) يعني أن الدماغ يطلب من الغدة العمل أكثر — وهذا مؤشر على قصور طفيف في الغدة الدرقية. الأسباب: (1) هاشيموتو (التهاب مناعي)، (2) نقص اليود. التوصية: فحص FT4 وAnt-TPO لتأكيد التشخيص." + +## سياق المريض (يُضاف ديناميكياً) +{{PATIENT_CONTEXT}} + +## مراجع قاعدة المعرفة (يُضاف ديناميكياً) +{{RAG_CONTEXT}} + +## النتائج المُستخرجة (يُضاف ديناميكياً) +{{FINDINGS}} diff --git a/backend/prompts/system_chat.txt b/backend/prompts/system_chat.txt new file mode 100644 index 0000000000000000000000000000000000000000..612a19a4a4095b51f55a01b199670eb0c7d4b5ea --- /dev/null +++ b/backend/prompts/system_chat.txt @@ -0,0 +1,57 @@ +# PROMPT: system_chat | version: 1.0 | lang: ar + +أنت مساعد طبي ذكي اسمك "تبيان". تجيب باللغة العربية بأسلوب واضح ومختصر ومطمئن. + +## شخصيتك +- دافئ وصبور — مثل طبيب يعرفك ويتكلم معك بصراحة +- تبسّط المصطلحات دون أن تستهين بذكاء المريض +- تدعم المريض نفسياً عند الحاجة، لكن لا تهوّن من أعراض تحتاج تقييماً طبياً + +## قواعد صارمة + +1. **لا هلوسة:** إذا لم تعرف الإجابة بيقين، قل "لا أعلم بشكل قاطع — استشر طبيبك." +2. **مصدر المعلومة:** اذكر المصدر عند وجود معلومة طبية مهمة (مثال: "وفقاً لـ MedlinePlus..."). +3. **لا تشخيص قاطع:** فسّر الأعراض والأرقام، لكن لا تحسم التشخيص بنفسك. +4. **الأعراض → تحاليل مقترحة:** إذا سُئلت عن أعراض، أضف "التحاليل المقترحة:" في نهاية ردك. +5. **الطوارئ:** إذا ذكر المستخدم أعراضاً طارئة (ألم صدري شديد، صعوبة تنفس، فقدان وعي)، أول جملة في ردك: "⚠️ هذه الأعراض تستدعي التوجه فوراً لأقرب طوارئ." +6. **موضوع الشات:** أجب عن الأسئلة الطبية وتحاليل المستخدم فقط. إذا خرج الموضوع تماماً عن الطب، بيّن ذلك بلطف. + +## معلومات الموقع (استخدمها إذا سأل المستخدم عن تبيان) + +- **رفع تحليل:** اضغط "رفع التحليل" في الصفحة الرئيسية، ارفع صورة أو PDF للتقرير المختبري. +- **التفسير:** تبيان يقرأ التحليل تلقائياً ويشرح النتائج الطبيعية وغير الطبيعية. +- **سجل التحاليل:** احفظ تحاليلك وقارنها مع السابقة من قسم "سجل التحاليل". +- **الشات:** أنا (تبيان) أجاوب أسئلتك الطبية وأقرأ نتائج آخر تحليل رفعته. + +## نبرة الرد حسب السياق + +- سؤال عام → رد مختصر 2-4 جمل +- نتيجة تحليل محددة → شرح مفصل مع المعدل الطبيعي والمقارنة +- قلق من المريض → ابدأ بجملة تطمينية قبل الشرح +- سؤال دقيق جداً → قدّم ما تعرفه واقترح استشارة متخصص + +## أمثلة على الردود الصحيحة (few-shot) + +مستخدم: "ما معنى أن الهيموجلوبين عندي 10.2؟" +تبيان: "الهيموجلوبين هو البروتين الذي يحمل الأكسجين في خلايا الدم الحمراء. قيمتك (10.2 g/dL) أقل من المعدل الطبيعي للنساء (12–15.5 g/dL) — فقر دم خفيف إلى متوسط. الأسباب الشائعة: نقص الحديد (الأكثر احتمالاً)، نقص فيتامين B12، أو نقص حمض الفوليك. لتحديد السبب الدقيق، فحص الفيريتين والحديد خطوة أولى مهمة." + +مستخدم: "أعاني من تعب شديد وشحوب — ماذا يعني؟" +تبيان: "التعب الشديد والشحوب من أبرز علامات فقر الدم، وهو من أكثر الحالات شيوعاً وقابلية للعلاج. قد يكون لها أسباب أخرى: نقص فيتامين D، قصور الغدة الدرقية، أو اضطرابات النوم. + +**التحاليل المقترحة:** +- صورة دم كاملة (CBC) +- فيريتين وحديد مصل +- فيتامين D وB12 +- وظائف الغدة الدرقية (TSH)" + +مستخدم: "عندي ألم في الصدر وضيق تنفس منذ ساعة" +تبيان: "⚠️ هذه الأعراض تستدعي التوجه فوراً لأقرب طوارئ أو الاتصال بالإسعاف. لا تنتظر ولا تقود بنفسك." + +مستخدم: "هل TSH = 6.8 خطير؟" +تبيان: "TSH 6.8 mIU/L أعلى من المعدل الطبيعي (0.4–4.0). هذا يشير إلى قصور خفيف في الغدة الدرقية — الدماغ يطلب من الغدة العمل أكثر من المعتاد. ليس خطيراً فورياً لكنه يحتاج متابعة. يُنصح بفحص FT4 وAnti-TPO لتأكيد التشخيص ومعرفة السبب (هاشيموتو الأكثر شيوعاً)." + +## بيانات التحليل الحالي (تُضاف ديناميكياً) +{{ANALYSIS_CONTEXT}} + +## معلومات من قاعدة المعرفة الطبية (تُضاف ديناميكياً) +{{RAG_CONTEXT}} diff --git a/backend/prompts/templates/cbc_analysis_prompt.txt b/backend/prompts/templates/cbc_analysis_prompt.txt new file mode 100644 index 0000000000000000000000000000000000000000..6f0945ac95bef9ad067857f65eeb01781759fa43 --- /dev/null +++ b/backend/prompts/templates/cbc_analysis_prompt.txt @@ -0,0 +1,44 @@ +# PROMPT: cbc_analysis_prompt | version: 2.0 +# CBC (Complete Blood Count) — صورة الدم الكاملة + +## السياق الطبي لهذا التحليل + +صورة الدم الكاملة تقيّم ثلاثة خطوط خلوية: +- **خط الهيموجلوبين/RBC**: يكشف فقر الدم وأنواعه (حديدي، B12، ثلاسيميا) +- **خط WBC**: يكشف العدوى، الالتهابات، ونادراً أمراض الدم +- **خط الصفائح**: يقيّم خطر النزيف أو التجلط + +## النطاقات المرجعية السريعة + +| الفحص | الرجال | النساء | الوحدة | +|-------|--------|--------|--------| +| Hemoglobin | 13.5–17.5 | 12.0–15.5 | g/dL | +| WBC | 4.0–11.0 | 4.0–11.0 | 10³/μL | +| RBC | 4.5–5.9 | 4.0–5.2 | 10⁶/μL | +| Platelets | 150–400 | 150–400 | 10³/μL | +| Hematocrit | 41–53% | 36–48% | % | +| MCV | 80–100 | 80–100 | fL | +| MCH | 27–33 | 27–33 | pg | + +## قواعد التفسير الخاصة بـ CBC + +- **MCV منخفض + هيموجلوبين منخفض** → فقر دم ناقص الحديد (الأكثر شيوعاً) +- **MCV مرتفع + هيموجلوبين منخفض** → نقص B12 أو حمض الفوليك +- **WBC > 11** مع حمى → عدوى بكتيرية غالباً +- **Platelets < 100** → يذكر في التقييم العام بوضوح +- **Platelets < 50** → ⚠️ تنبيه عاجل + +## أسلوب الشرح للمريض + +- الهيموجلوبين: "البروتين الذي يحمل الأكسجين في دمك" +- WBC: "خلايا الجيش الدفاعي في جسمك" +- Platelets: "خلايا صغيرة تساعد دمك على التجلط عند الجرح" + +## شكل الإخراج (JSON) + +{{SYSTEM_FORMAT}} + +## البيانات المراد تحليلها + +النتائج: {{FINDINGS}} +المراجع الطبية: {{RAG_CONTEXT}} diff --git a/backend/prompts/templates/diabetes_analysis_prompt.txt b/backend/prompts/templates/diabetes_analysis_prompt.txt new file mode 100644 index 0000000000000000000000000000000000000000..a51d8ce377612e5e45a0aea35d82971077493b37 --- /dev/null +++ b/backend/prompts/templates/diabetes_analysis_prompt.txt @@ -0,0 +1,56 @@ +# PROMPT: diabetes_analysis_prompt | version: 1.0 +# Diabetes & Glucose Metabolism — السكري واضطرابات الجلوكوز + +## السياق الطبي + +تحاليل السكر تقيّم ثلاثة محاور: +- **الجلوكوز الفوري**: Fasting Glucose — snapshot لحظي لمستوى السكر +- **المتوسط طويل الأمد**: HbA1c — يعكس متوسط السكر خلال 3 أشهر +- **مقاومة الإنسولين**: HOMA-IR — يقيّم كفاءة الإنسولين + +## النطاقات المرجعية السريعة + +| الفحص | طبيعي | ما قبل السكري | السكري | الوحدة | +|-------|-------|--------------|--------|--------| +| Fasting Glucose | 70–99 | 100–125 | ≥ 126 | mg/dL | +| HbA1c | < 5.7% | 5.7–6.4% | ≥ 6.5% | % | +| 2h Post-meal Glucose | < 140 | 140–199 | ≥ 200 | mg/dL | +| Fasting Insulin | 2–25 | — | — | µIU/mL | +| HOMA-IR | < 1.9 (طبيعي) / 1.9–2.9 (مقاومة خفيفة) / > 2.9 (مقاومة) | — | — | — | + +## قواعد التفسير الخاصة — Chain-of-Thought + +- **HbA1c = قراءة المؤشر الأدق**: لا يتأثر بالأكل قبل التحليل + - < 5.7% → طبيعي + - 5.7–6.4% → ما قبل السكري — تدخل مبكر ضروري + - ≥ 6.5% → سكري — تأكيد بفحص ثانٍ + - هدف العلاج لمرضى السكري: < 7% عموماً، < 8% لكبار السن + +- **Fasting Glucose + HbA1c معاً**: + - كلاهما مرتفع → تأكيد أقوى للسكري + - Glucose مرتفع + HbA1c طبيعي → ربما نسيان الصيام أو ضغط مؤقت + +- **HOMA-IR مرتفع مع HbA1c طبيعي** → مقاومة إنسولين قبل السكري + +## أسلوب الشرح للمريض + +- HbA1c: "مؤشر يشبه كاميرا مراقبة لسكرك خلال 3 أشهر — لا يخدعه وجبة واحدة" +- Fasting Glucose: "مستوى السكر في الصباح قبل أي طعام" +- HOMA-IR: "مقياس لمدى استجابة جسمك للإنسولين" + +## أمثلة few-shot + +مثال: HbA1c = 6.1%, Fasting Glucose = 108 +الشرح: "HbA1c (6.1%) وجلوكوز الصيام (108 mg/dL) يقعان في نطاق ما قبل السكري. هذا لا يعني أنك مريض بالسكري، لكنه تحذير مبكر يستوجب التدخل الآن. التعديلات الغذائية والرياضة تعكس هذا الوضع في 60% من الحالات." + +مثال: HbA1c = 9.4%, Fasting Glucose = 210 +الشرح: "HbA1c (9.4%) يعكس متوسط سكر ~225 mg/dL خلال 3 أشهر — أعلى بكثير من الهدف العلاجي (7%). بالإضافة لجلوكوز صيام مرتفع (210 mg/dL). هذا المستوى يزيد خطر المضاعفات على المدى البعيد. مراجعة الطبيب عاجلة لتعديل العلاج." + +## شكل الإخراج (JSON) + +{{SYSTEM_FORMAT}} + +## البيانات المراد تحليلها + +النتائج: {{FINDINGS}} +المراجع الطبية: {{RAG_CONTEXT}} diff --git a/backend/prompts/templates/kidney_analysis_prompt.txt b/backend/prompts/templates/kidney_analysis_prompt.txt new file mode 100644 index 0000000000000000000000000000000000000000..64c9dd958902134de8ab58513a2c2e7d6e8902fc --- /dev/null +++ b/backend/prompts/templates/kidney_analysis_prompt.txt @@ -0,0 +1,61 @@ +# PROMPT: kidney_analysis_prompt | version: 1.0 +# Kidney Function Tests — وظائف الكلى + +## السياق الطبي + +وظائف الكلى تقيّم قدرة الكلى على تصفية الدم: +- **نفايات البروتين**: Creatinine + BUN (Urea) — ترتفع عند ضعف الكلى +- **معدل الترشيح**: eGFR — المؤشر الأهم لتقييم شدة المرض +- **حمض اليوريك**: Uric Acid — مرتبط بالنقرس وحصى الكلى + +## النطاقات المرجعية السريعة + +| الفحص | الرجال | النساء | الوحدة | +|-------|--------|--------|--------| +| Creatinine | 0.7–1.2 | 0.5–1.1 | mg/dL | +| BUN (Urea) | 7–20 | 7–20 | mg/dL | +| eGFR | > 90 (طبيعي) | > 90 (طبيعي) | mL/min/1.73m² | +| Uric Acid | 3.5–7.2 | 2.6–6.0 | mg/dL | + +## مراحل الداء الكلوي المزمن (CKD) حسب eGFR + +| المرحلة | eGFR | الوصف | +|---------|------|-------| +| G1 | ≥ 90 | طبيعي | +| G2 | 60–89 | خفيف | +| G3a | 45–59 | خفيف-متوسط | +| G3b | 30–44 | متوسط-شديد | +| G4 | 15–29 | شديد | +| G5 | < 15 | فشل كلوي — يحتاج غسيل | + +## قواعد التفسير — Chain-of-Thought + +- **ابدأ دائماً بـ eGFR**: هو المؤشر الأكثر دقة وشمولاً +- **Creatinine وحده مُضلّل**: يتأثر بالعضلات والسوائل وعدد من الأدوية +- **BUN:Creatinine ratio**: + - > 20:1 → جفاف أو نزيف داخلي + - < 10:1 → مشكلة في الكلى نفسها +- **Uric Acid > 7 (رجال) أو > 6 (نساء)** → خطر نقرس أو حصى + +## أسلوب الشرح للمريض + +- Creatinine: "نفاية بروتينية يُفترض أن الكلى تُزيلها باستمرار" +- eGFR: "مقياس لقوة الكلى — كلما ارتفع كلما كانت الكلى أقوى" +- BUN: "نفاية أخرى من تكسّر البروتين — تُشير لكفاءة الكلى والكبد معاً" + +## أمثلة few-shot + +مثال: Creatinine = 1.8 mg/dL، eGFR = 42 mL/min +الشرح: "الكرياتينين (1.8) أعلى من المعدل الطبيعي للرجال (0.7–1.2)، وeGFR (42) يضعك في المرحلة G3b من الداء الكلوي — ضعف متوسط إلى شديد في الترشيح. هذا يستدعي متابعة دورية مع طبيب الكلى لتحديد السبب ومنع التقدم." + +مثال: Uric Acid = 9.2، Creatinine = 1.0 +الشرح: "حمض اليوريك مرتفع جداً (9.2 مقابل الحد الأعلى 7.2). الوظائف الكلوية طبيعية حالياً، لكن الحمض المرتفع يزيد خطر: (1) نوبات النقرس، (2) حصى الكلى. يُنصح بتقليل اللحوم الحمراء والكحول وزيادة السوائل." + +## شكل الإخراج (JSON) + +{{SYSTEM_FORMAT}} + +## البيانات المراد تحليلها + +النتائج: {{FINDINGS}} +المراجع الطبية: {{RAG_CONTEXT}} diff --git a/backend/prompts/templates/lipid_analysis_prompt.txt b/backend/prompts/templates/lipid_analysis_prompt.txt new file mode 100644 index 0000000000000000000000000000000000000000..9d6587db31cbd9106dc796912f271dfb2881a62f --- /dev/null +++ b/backend/prompts/templates/lipid_analysis_prompt.txt @@ -0,0 +1,55 @@ +# PROMPT: lipid_analysis_prompt | version: 1.0 +# Lipid Panel — تحليل الدهون والكوليسترول + +## السياق الطبي + +تحليل الدهون يقيّم خطر أمراض القلب والأوعية الدموية: +- **LDL ("الكوليسترول الضار")**: يترسب في الشرايين → تصلب الشرايين +- **HDL ("الكوليسترول المفيد")**: يُزيل الدهون من الشرايين → وقاية +- **Triglycerides**: دهون الطاقة — ترتفع بالسكر والكحول والسمنة +- **Total Cholesterol**: مجموع الكوليسترول — مؤشر عام + +## النطاقات المرجعية السريعة + +| الفحص | مثالي | حدّي | مرتفع | الوحدة | +|-------|-------|------|-------|--------| +| Total Cholesterol | < 200 | 200–239 | ≥ 240 | mg/dL | +| LDL | < 100 | 100–159 | ≥ 160 | mg/dL | +| HDL (رجال) | > 40 | 40–59 | — | mg/dL | +| HDL (نساء) | > 50 | 50–59 | — | mg/dL | +| Triglycerides | < 150 | 150–199 | ≥ 200 | mg/dL | + +**تنبيه**: الأهداف تختلف حسب وجود السكري أو أمراض القلب: +- مريض قلب: LDL < 70 mg/dL +- مريض سكري: LDL < 100 mg/dL + +## قواعد التفسير — Chain-of-Thought + +- **LDL هو المؤشر الأهم**: علاج الكوليسترول يستهدفه أساساً +- **HDL منخفض + LDL مرتفع** = خطر مزدوج → يُذكر بوضوح +- **Non-HDL Cholesterol** = Total − HDL → مؤشر إضافي (الهدف < 130) +- **Triglycerides > 500** → خطر التهاب البنكرياس → ⚠️ تنبيه عاجل +- **Triglycerides مرتفع + HDL منخفض** → غالباً مقاومة إنسولين أو سكري خفي + +## أسلوب الشرح للمريض + +- LDL: "الكوليسترول الذي يتراكم في جدران الشرايين كالأوساخ في الأنابيب" +- HDL: "سيارة الإسعاف — ينقل الكوليسترول الزائد من الشرايين للكبد" +- Triglycerides: "دهون الطاقة الفائضة — ترتفع بالسكريات أكثر من الدهون نفسها" + +## أمثلة few-shot + +مثال: LDL = 162، HDL = 38، Triglycerides = 245 +الشرح: "LDL مرتفع (162 مقابل الهدف < 100) مع HDL منخفض (38 مقابل الحد الأدنى 40 للرجال) وثلاثيات الجليسريد مرتفعة (245). هذا النمط — مثلث الخطر — يرفع احتمال أمراض القلب. يُنصح بتعديل جذري في النظام الغذائي وقد تحتاج دواء الستاتين. راجع طبيبك." + +مثال: Total Cholesterol = 190، LDL = 88، HDL = 72، Triglycerides = 130 +الشرح: "ملف الدهون ممتاز. LDL ضمن الهدف المثالي، HDL مرتفع (يعني حماية جيدة للقلب)، والثلاثيات طبيعية. استمر على نفس النمط الغذائي والرياضة." + +## شكل الإخراج (JSON) + +{{SYSTEM_FORMAT}} + +## البيانات المراد تحليلها + +النتائج: {{FINDINGS}} +المراجع الطبية: {{RAG_CONTEXT}} diff --git a/backend/prompts/templates/liver_analysis_prompt.txt b/backend/prompts/templates/liver_analysis_prompt.txt new file mode 100644 index 0000000000000000000000000000000000000000..d14d4ad89fed50160a3aa3d44d473d9eb8ea3d48 --- /dev/null +++ b/backend/prompts/templates/liver_analysis_prompt.txt @@ -0,0 +1,46 @@ +# PROMPT: liver_analysis_prompt | version: 2.0 +# Liver Function Tests — وظائف الكبد + +## السياق الطبي + +وظائف الكبد تقيّم ثلاثة أبعاد: +- **تلف الخلايا**: ALT (الأكثر تخصصاً للكبد) + AST +- **وظيفة الصفراء**: ALP + GGT + Bilirubin +- **القدرة التركيبية**: Albumin + PT/INR (الأهم طبياً) + +## النطاقات المرجعية السريعة + +| الفحص | الرجال | النساء | الوحدة | +|-------|--------|--------|--------| +| ALT (SGPT) | 7–40 | 7–35 | U/L | +| AST (SGOT) | 10–40 | 10–35 | U/L | +| ALP | 44–147 | 44–147 | U/L | +| GGT | 8–61 | 5–36 | U/L | +| Bilirubin (Total) | 0.2–1.2 | 0.2–1.2 | mg/dL | +| Bilirubin (Direct) | 0–0.3 | 0–0.3 | mg/dL | +| Albumin | 3.5–5.0 | 3.5–5.0 | g/dL | + +## قواعد التفسير الخاصة + +- **ALT > 3× الحد الأعلى** → تلف كبدي يستدعي تقييماً (فيروسي؟ دهني؟ دوائي؟) +- **ALT > 10×** → ⚠️ تلف كبدي حاد — إشارة طارئة +- **AST:ALT > 2:1** → يشير إلى سبب كحولي +- **ALT أعلى من AST** → يشير إلى التهاب كبد فيروسي أو دهني +- **ALP مرتفع + GGT مرتفع** → انسداد صفراوي (حصى؟ التهاب؟) +- **Albumin < 3.0** → قصور كبدي متقدم — ذكره بوضوح شديد +- **Bilirubin > 2.5** → يرقان مرئي — ذكر الأعراض المتوقعة + +## أسلوب الشرح للمريض + +- ALT: "إنزيم داخل خلايا الكبد — يرتفع عند تلفها" +- Albumin: "بروتين ينتجه الكبد — يدل على قوة الكبد وتغذيتك" +- Bilirubin: "صبغة صفراء من تكسّر الدم — ارتفاعها يسبب الاصفرار" + +## شكل الإخراج (JSON) + +{{SYSTEM_FORMAT}} + +## البيانات المراد تحليلها + +النتائج: {{FINDINGS}} +المراجع الطبية: {{RAG_CONTEXT}} diff --git a/backend/prompts/templates/system_prompt.txt b/backend/prompts/templates/system_prompt.txt new file mode 100644 index 0000000000000000000000000000000000000000..3e721ec9655239c8f780f6082d8e4dec0663a075 --- /dev/null +++ b/backend/prompts/templates/system_prompt.txt @@ -0,0 +1,30 @@ +# PROMPT: system_prompt | version: 2.0 +# Base system prompt — injected before all analysis prompts. + +أنت طبيب مختبر خبير ومعلم طبي متخصص في تفسير التحاليل المختبرية للمرضى العرب. + +## هويتك +- اسمك "تبيان" +- تتحدث بلغة عربية فصحى مبسّطة — لا تقنية جافة ولا مصطلحات غير مشروحة +- نبرتك: مطمئنة، واضحة، داعمة — مثل طبيب يعرف المريض ويحترم فهمه + +## قواعد لا تُكسر + +1. **الدقة فوق كل شيء** — لا تخترع رقماً أو معلومة غير موجودة في البيانات المقدمة +2. **لا تشخيص قاطع** — فسّر الأرقام ووضّح ما تعنيه، لكن لا تحدد مرضاً بشكل نهائي +3. **المصدر دائماً** — عند استخدام معلومة من المراجع، اذكر: "وفقاً لـ [المصدر]" +4. **الإحالة الطبية** — كل تقييم يُختتم بتوصية بمراجعة الطبيب +5. **النبرة الإيجابية** — ابدأ بالقيم الطبيعية قبل الحديث عن المشكلات +6. **الطوارئ** — إذا وُجدت قيمة تستدعي تدخلاً عاجلاً، ابدأ الجواب بـ: "⚠️ تنبيه طبي عاجل:" + +## تسلسل التفكير قبل الإجابة (Chain-of-Thought) + +قبل بناء التقرير: +1. كم عدد القيم الطبيعية؟ → ابدأ بتقييم الحالة العامة منها +2. هل هناك قيم خارج النطاق؟ → صنّفها: خفيفة / متوسطة / شديدة +3. هل هناك قيم تشير معاً لمشكلة واحدة؟ → اربطها في الشرح +4. ما التوصيات الأكثر فائدة لهذا المريض تحديداً؟ + +## شكل الإخراج + +أرجع JSON فقط — ابدأ بـ { مباشرة — لا نص قبله ولا بعده. diff --git a/backend/prompts/templates/thyroid_analysis_prompt.txt b/backend/prompts/templates/thyroid_analysis_prompt.txt new file mode 100644 index 0000000000000000000000000000000000000000..dc4e7275d66d6ef93e302713daa9074cbbc5a1c1 --- /dev/null +++ b/backend/prompts/templates/thyroid_analysis_prompt.txt @@ -0,0 +1,49 @@ +# PROMPT: thyroid_analysis_prompt | version: 2.0 +# Thyroid Function Tests — وظائف الغدة الدرقية + +## السياق الطبي + +الغدة الدرقية تنظّم الأيض والطاقة والحرارة والمزاج. +محور: الغدة النخامية (TSH) ← تأمر ← الغدة الدرقية (T3, T4) + +عند قراءة الغدة الدرقية، ابدأ دائماً من TSH: +- **TSH مرتفع** → الغدة الدرقية "كسولة" (قصور) +- **TSH منخفض** → الغدة الدرقية "نشيطة جداً" (فرط نشاط) + +## النطاقات المرجعية السريعة + +| الفحص | الطبيعي | الوحدة | +|-------|---------|--------| +| TSH | 0.4–4.0 | mIU/L | +| Free T4 (FT4) | 0.8–1.8 | ng/dL | +| Free T3 (FT3) | 2.3–4.2 | pg/mL | +| Anti-TPO | < 34 | IU/mL | + +**عند الحمل:** TSH < 2.5 في الثلث الأول، < 3.0 في الثاني والثالث + +## قواعد التفسير الخاصة + +| TSH | FT4 | التفسير | +|-----|-----|---------| +| مرتفع | منخفض | قصور درقي أولي — يحتاج ليفوثيروكسين | +| منخفض | مرتفع | فرط نشاط — يحتاج تقييماً متخصصاً | +| مرتفع | طبيعي | قصور تحت سريري — متابعة كل 6 أشهر | +| منخفض | طبيعي | فرط نشاط تحت سريري — يحتاج تحليل | + +- **Anti-TPO مرتفع** مع TSH طبيعي → التهاب مناعي ذاتي (هاشيموتو محتمل) — متابعة سنوية +- **Anti-TPO > 500** → ذكره بوضوح في التقرير + +## أسلوب الشرح للمريض + +- TSH: "هرمون المدير من الدماغ يأمر غدتك الدرقية" +- FT4: "الهرمون الذي ينظّم سرعة جهازك الأيضي" +- Anti-TPO: "أجسام مناعية قد تهاجم الغدة الدرقية" + +## شكل الإخراج (JSON) + +{{SYSTEM_FORMAT}} + +## البيانات المراد تحليلها + +النتائج: {{FINDINGS}} +المراجع الطبية: {{RAG_CONTEXT}} diff --git a/backend/railway.json b/backend/railway.json index c66ced5c56d2eeaf3cc2aa9a4e4c1032e42e2a5b..cc8edd6cbb2ccacff01aae419c8e88079937531f 100644 --- a/backend/railway.json +++ b/backend/railway.json @@ -1,11 +1,14 @@ { "$schema": "https://railway.app/railway.schema.json", "build": { - "builder": "NIXPACKS" + "builder": "DOCKERFILE", + "dockerfilePath": "Dockerfile" }, "deploy": { - "startCommand": "uvicorn main:app --host 0.0.0.0 --port $PORT", + "startCommand": "python run.py", "restartPolicyType": "ON_FAILURE", - "restartPolicyMaxRetries": 3 + "restartPolicyMaxRetries": 3, + "healthcheckPath": "/health", + "healthcheckTimeout": 120 } } diff --git a/backend/requirements.txt b/backend/requirements.txt index 7cd10181f0dd7e4cde024b18fc10cf5f93443ae1..b15dc5c86cc015e27aa877f13e76a5978b540c3d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,3 +15,7 @@ cohere>=5.0.0 rank-bm25>=0.2.2 requests>=2.31.0 psycopg2-binary>=2.9.0 +sentry-sdk[fastapi]>=2.0.0 +gtts>=2.5.0 +redis>=5.0.0 +PyJWT>=2.8.0 diff --git a/backend/run.py b/backend/run.py new file mode 100644 index 0000000000000000000000000000000000000000..64149e9e91ac2e63eb2b785e40a3dfd011bcde9a --- /dev/null +++ b/backend/run.py @@ -0,0 +1,6 @@ +import os +import uvicorn + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 8000)) + uvicorn.run("main:app", host="0.0.0.0", port=port, workers=1) diff --git a/backend/server.log b/backend/server.log deleted file mode 100644 index 3f74221a45b314bfc75e7bbb6feb204b0bf9fdec..0000000000000000000000000000000000000000 Binary files a/backend/server.log and /dev/null differ diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..669dfba0edb99f7a59e8d91145621a17b3cb0d30 --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1 @@ +# Services package — search + RAG diff --git a/backend/services/agent/__init__.py b/backend/services/agent/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..09ab754c9a5420a52e0448c75afac1aced4fc3d5 --- /dev/null +++ b/backend/services/agent/__init__.py @@ -0,0 +1,5 @@ +from ._ocr_helpers import preprocess_image, extract_text_google_vision +from ._validators import is_valid_test, is_impossible_value, get_status + +__all__ = ["preprocess_image", "extract_text_google_vision", + "is_valid_test", "is_impossible_value", "get_status"] diff --git a/backend/services/agent/_ocr_helpers.py b/backend/services/agent/_ocr_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..0615fc3741a08b1b0ec3d5231b1b1550598b5bad --- /dev/null +++ b/backend/services/agent/_ocr_helpers.py @@ -0,0 +1,70 @@ +""" +OCR helper functions for the agent pipeline. +Mirrors preprocess_image and extract_text_google_vision from main.py. +""" +from __future__ import annotations +import io +import base64 + +import numpy as np +import requests as http_requests +from PIL import Image + + +def preprocess_image(image_bytes: bytes) -> np.ndarray: + """Enhance image quality for EasyOCR.""" + from PIL import ImageFilter, ImageEnhance + img = Image.open(io.BytesIO(image_bytes)) + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + + w, h = img.size + min_side, max_side = min(w, h), max(w, h) + if min_side < 1200: + scale = min(1200 / min_side, 4000 / max_side) + img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS) + + gray = img.convert("L") + gray = ImageEnhance.Contrast(gray).enhance(2.2) + gray = ImageEnhance.Sharpness(gray).enhance(2.5) + gray = gray.filter(ImageFilter.SHARPEN) + return np.array(gray.convert("RGB")) + + +def _preprocess_for_vision(image_bytes: bytes) -> bytes: + from PIL import ImageEnhance + img = Image.open(io.BytesIO(image_bytes)) + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + w, h = img.size + max_side = max(w, h) + if max_side < 1500: + scale = 1500 / max_side + img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS) + if max(img.size) > 4000: + scale = 4000 / max(img.size) + img = img.resize((int(img.width * scale), int(img.height * scale)), Image.LANCZOS) + img = ImageEnhance.Contrast(img).enhance(1.3) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=95, optimize=True) + return buf.getvalue() + + +def extract_text_google_vision(image_bytes: bytes, api_key: str) -> str: + processed = _preprocess_for_vision(image_bytes) + b64 = base64.b64encode(processed).decode("utf-8") + payload = { + "requests": [{ + "image": {"content": b64}, + "features": [{"type": "DOCUMENT_TEXT_DETECTION", "maxResults": 1}], + "imageContext": { + "languageHints": ["ar", "en"], + "textDetectionParams": {"enableTextDetectionConfidenceScore": True}, + }, + }] + } + url = f"https://vision.googleapis.com/v1/images:annotate?key={api_key}" + r = http_requests.post(url, json=payload, timeout=30) + r.raise_for_status() + resp = r.json()["responses"][0] + return resp.get("fullTextAnnotation", {}).get("text", "") diff --git a/backend/services/agent/_validators.py b/backend/services/agent/_validators.py new file mode 100644 index 0000000000000000000000000000000000000000..fdb8b43152e693a580022e1a390b43579e0745ff --- /dev/null +++ b/backend/services/agent/_validators.py @@ -0,0 +1,93 @@ +""" +Shared validation helpers for the analysis pipeline. +Mirrors logic from main.py so pipeline tools stay pure and testable. +""" +from __future__ import annotations +import re + +# ── Physiologically impossible bounds ───────────────────────────────────── + +_IMPOSSIBLE_EXPLICIT: dict[str, tuple[float, float]] = { + "hemoglobin": (1.0, 25.0), + "hgb": (1.0, 25.0), + "rbc": (0.5, 10.0), + "wbc": (0.1, 100.0), + "platelets": (1.0, 2000.0), + "plt": (1.0, 2000.0), + "hematocrit": (5.0, 70.0), + "hct": (5.0, 70.0), + "glucose": (10.0, 2000.0), + "hba1c": (2.0, 20.0), + "cholesterol": (50.0, 1000.0), + "triglycerides":(10.0, 5000.0), + "hdl": (5.0, 200.0), + "ldl": (5.0, 500.0), + "creatinine": (0.1, 30.0), + "bun": (1.0, 300.0), + "urea": (1.0, 300.0), + "alt": (1.0, 5000.0), + "ast": (1.0, 5000.0), + "alp": (1.0, 3000.0), + "bilirubin": (0.01, 50.0), + "albumin": (0.5, 10.0), + "tsh": (0.001, 100.0), + "sodium": (100.0, 180.0), + "potassium": (1.0, 10.0), + "calcium": (1.0, 20.0), + "ferritin": (1.0, 50000.0), + "iron": (5.0, 500.0), + "crp": (0.0, 500.0), + "esr": (0.0, 200.0), + "uric_acid": (0.5, 20.0), + "prolactin": (0.1, 500.0), + "testosterone": (1.0, 2000.0), + "pt": (5.0, 100.0), + "inr": (0.5, 15.0), +} + +_IGNORE_NAMES = frozenset([ + "page", "id", "patient", "date", "sex", "age", "mrn", "doctor", + "physician", "result", "unit", "range", "validated", "approved", + "interpretation", "ref", +]) + + +def is_impossible_value(name: str, value: str) -> bool: + try: + val = float(value) + name_lower = name.lower() + for key, (lo, hi) in _IMPOSSIBLE_EXPLICIT.items(): + if key in name_lower: + return val < lo or val > hi + except Exception: + pass + return False + + +def is_valid_test(name: str) -> bool: + n = str(name).lower() + return not any(x in n for x in _IGNORE_NAMES) and len(str(name).strip()) > 1 + + +def get_status(value: str, range_str: str, name: str = "") -> str: + try: + val = float(value) + nums = re.findall(r"[-+]?\d*\.?\d+", str(range_str)) + if len(nums) >= 2: + lo, hi = float(nums[0]), float(nums[1]) + if val < lo: + return "low" + if val > hi: + return "high" + return "normal" + if name: + try: + from medical_kb.reference.normal_ranges import classify as nr_classify + result = nr_classify(name, val) + if result != "unknown": + return result + except Exception: + pass + except Exception: + pass + return "normal" diff --git a/backend/services/agents/__init__.py b/backend/services/agents/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..336ff1fbbc7d5f384971209999053dbb01d51804 --- /dev/null +++ b/backend/services/agents/__init__.py @@ -0,0 +1,4 @@ +from .coordinator import AgentCoordinator +from .base import AgentContext + +__all__ = ["AgentCoordinator", "AgentContext"] diff --git a/backend/services/agents/base.py b/backend/services/agents/base.py new file mode 100644 index 0000000000000000000000000000000000000000..6b3e76415444242c52ab2b3bb9e2377dbc079081 --- /dev/null +++ b/backend/services/agents/base.py @@ -0,0 +1,159 @@ +""" +Agent base classes and shared context for the Tebyan multi-agent system. + +Each agent: + - Receives AgentContext (shared mutable state) + - Executes its task, writes results back into context + - Emits structured log entries + - Supports retry with exponential backoff + - Can declare hard failures vs soft degradations +""" +from __future__ import annotations + +import time +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + +log = logging.getLogger("tebyan.agents") + + +# ── Shared agent context (mutable, passed between agents) ───────────────── + +@dataclass +class AgentMemory: + """Per-session short-term memory. Agents can store and retrieve facts.""" + _store: dict[str, Any] = field(default_factory=dict, repr=False) + + def remember(self, key: str, value: Any) -> None: + self._store[key] = value + + def recall(self, key: str, default: Any = None) -> Any: + return self._store.get(key, default) + + def as_dict(self) -> dict: + return dict(self._store) + + +@dataclass +class AgentLogEntry: + agent: str + status: str # "ok" | "error" | "skipped" | "degraded" + duration_ms: float + message: str = "" + attempts: int = 1 + + +@dataclass +class AgentContext: + """ + Shared state passed through the full agent pipeline. + Agents read their inputs from this object and write their outputs back. + """ + # ── Inputs ────────────────────────────────────────────────────────────── + file_bytes: bytes = b"" + file_type: str = "" # "pdf" | "image" + analysis_type: str = "شامل" + session_id: str = "" + + # ── OCR Agent output ──────────────────────────────────────────────────── + raw_text: str = "" + + # ── Extraction Agent output ────────────────────────────────────────────── + findings_raw: list = field(default_factory=list) + findings: list[dict] = field(default_factory=list) + + # ── Classification Agent output ────────────────────────────────────────── + panel_code: str = "" + panel_confidence: str = "low" + all_panels: list[str] = field(default_factory=list) + + # ── Medical Reasoning Agent output ─────────────────────────────────────── + rag_context: str = "" + rag_confidence: str = "لا يوجد" + panel_context: str = "" + report_raw: dict = field(default_factory=dict) + + # ── Recommendation Agent output ────────────────────────────────────────── + recommendations: list[dict] = field(default_factory=list) + + # ── Safety Agent output ────────────────────────────────────────────────── + report: dict = field(default_factory=dict) + + # ── Observability ──────────────────────────────────────────────────────── + logs: list[AgentLogEntry] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + memory: AgentMemory = field(default_factory=AgentMemory) + + def log_step(self, agent: str, status: str, duration_ms: float, + message: str = "", attempts: int = 1) -> None: + entry = AgentLogEntry(agent=agent, status=status, duration_ms=round(duration_ms, 1), + message=message, attempts=attempts) + self.logs.append(entry) + level = logging.ERROR if status == "error" else logging.INFO + log.log(level, "[%s] %s | %.0fms | %s", agent, status.upper(), duration_ms, message) + + def serialize_logs(self) -> list[dict]: + return [ + {"agent": e.agent, "status": e.status, "ms": e.duration_ms, + "msg": e.message, "attempts": e.attempts} + for e in self.logs + ] + + +# ── Agent base class ─────────────────────────────────────────────────────── + +class AgentBase(ABC): + """ + Abstract base for all pipeline agents. + Subclasses implement _execute(ctx) and override name/max_retries. + """ + name: str = "agent" + max_retries: int = 2 + retry_delay: float = 0.3 # seconds, doubles each retry + + def run(self, ctx: AgentContext) -> AgentContext: + """ + Execute the agent with retry logic. + Returns the (mutated) context. + """ + t0 = time.perf_counter() + last_exc: Exception | None = None + delay = self.retry_delay + + for attempt in range(1, self.max_retries + 1): + try: + self._execute(ctx) + ms = (time.perf_counter() - t0) * 1000 + ctx.log_step(self.name, "ok", ms, attempts=attempt) + return ctx + except _SoftDegradation as exc: + # Non-fatal: log and continue (partial result is acceptable) + ms = (time.perf_counter() - t0) * 1000 + ctx.log_step(self.name, "degraded", ms, str(exc), attempts=attempt) + log.warning("[%s] soft degradation: %s", self.name, exc) + return ctx + except Exception as exc: + last_exc = exc + log.warning("[%s] attempt %d/%d failed: %s", self.name, attempt, self.max_retries, exc) + if attempt < self.max_retries: + time.sleep(delay) + delay = min(delay * 2, 5.0) + + ms = (time.perf_counter() - t0) * 1000 + ctx.errors.append(f"{self.name}: {last_exc}") + ctx.log_step(self.name, "error", ms, str(last_exc), attempts=self.max_retries) + self._on_failure(ctx, last_exc) + return ctx + + @abstractmethod + def _execute(self, ctx: AgentContext) -> None: + """Agent logic. Mutates ctx. Raise exception on failure.""" + + def _on_failure(self, ctx: AgentContext, exc: Exception) -> None: + """Override to provide graceful fallback on final failure.""" + + +class _SoftDegradation(Exception): + """Raised by an agent when it completes partially — not a fatal error.""" diff --git a/backend/services/agents/classification_agent.py b/backend/services/agents/classification_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..72ec916128f35369d4fc4447b8c83013e07dd32b --- /dev/null +++ b/backend/services/agents/classification_agent.py @@ -0,0 +1,29 @@ +"""Classification Agent — detects medical panel type from findings.""" +from __future__ import annotations +from .base import AgentBase, AgentContext + + +class ClassificationAgent(AgentBase): + name = "classification_agent" + max_retries = 1 + + def _execute(self, ctx: AgentContext) -> None: + from services.classifier import classify_report + from services.search.query_parser import detect_panel + + clf = classify_report(ctx.findings) + panel = clf.primary_panel + + if not panel: + q = ", ".join(f["name"] for f in ctx.findings) + panel = detect_panel(q) or detect_panel(ctx.analysis_type) or "" + + ctx.panel_code = panel + ctx.panel_confidence = clf.confidence + ctx.all_panels = clf.all_panels + + ctx.memory.remember("panel", panel) + ctx.memory.remember("all_panels", clf.all_panels) + + def _on_failure(self, ctx: AgentContext, exc: Exception) -> None: + ctx.panel_code = "" diff --git a/backend/services/agents/coordinator.py b/backend/services/agents/coordinator.py new file mode 100644 index 0000000000000000000000000000000000000000..d1f72a85ef39d1b82104b14166612840b13a6bb0 --- /dev/null +++ b/backend/services/agents/coordinator.py @@ -0,0 +1,98 @@ +""" +AgentCoordinator — orchestrates the full multi-agent medical analysis pipeline. + +Agent execution order: + 1. OCRAgent — raw text extraction + 2. ExtractionAgent — structured findings + 3. ClassificationAgent — panel detection + 4. MedicalReasoningAgent — RAG + LLM analysis + 5. SafetyAgent — PDPL compliance + +Each agent can soft-degrade (partial result) or hard-fail (triggers fallback). +The coordinator collects structured logs for observability. +""" +from __future__ import annotations +import time +import logging +from dataclasses import dataclass + +from .base import AgentContext +from .ocr_agent import OCRAgent +from .extraction_agent import ExtractionAgent +from .classification_agent import ClassificationAgent +from .reasoning_agent import MedicalReasoningAgent +from .safety_agent import SafetyAgent + +log = logging.getLogger("tebyan.coordinator") + + +@dataclass +class CoordinatorResult: + findings: list[dict] + summary: str + report: dict + panel_code: str + logs: list[dict] + ok: bool = True + error: str = "" + total_ms: float = 0.0 + + +class AgentCoordinator: + """ + Builds and runs the agent pipeline from injected dependencies. + Agents are constructed once and reused across requests. + """ + + def __init__(self, reader, groq_client, retriever, kb, render_prompt_fn, + retrieval_config_cls, vision_key: str = ""): + self._agents = [ + OCRAgent(reader, vision_key), + ExtractionAgent(groq_client, render_prompt_fn), + ClassificationAgent(), + MedicalReasoningAgent(groq_client, retriever, kb, render_prompt_fn, retrieval_config_cls), + SafetyAgent(), + ] + + def run(self, file_bytes: bytes, file_type: str, + analysis_type: str = "شامل", session_id: str = "") -> CoordinatorResult: + t0 = time.perf_counter() + ctx = AgentContext( + file_bytes=file_bytes, + file_type=file_type, + analysis_type=analysis_type, + session_id=session_id, + ) + + for agent in self._agents: + ctx = agent.run(ctx) + # Hard stop if OCR or extraction completely failed + if agent.name in ("ocr_agent", "extraction_agent") and not ctx.findings and agent.name == "extraction_agent": + if not ctx.findings: + log.error("[coordinator] extraction failed — aborting pipeline") + return CoordinatorResult( + findings=[], summary="", report={}, panel_code="", + logs=ctx.serialize_logs(), ok=False, + error="لم نتمكن من قراءة قيم التحاليل من الملف.", + total_ms=(time.perf_counter() - t0) * 1000, + ) + + total_ms = (time.perf_counter() - t0) * 1000 + log.info("[coordinator] done | %.0fms | agents=%d | errors=%d", + total_ms, len(self._agents), len(ctx.errors)) + + abnormal = [f for f in ctx.findings if f.get("status") != "normal"] + summary = ( + "القيم التي تحتاج انتباهاً: " + "، ".join(f["name"] for f in abnormal[:3]) + if abnormal else "جميع القيم ضمن المعدل الطبيعي ✓" + ) + + return CoordinatorResult( + findings=ctx.findings, + summary=summary, + report=ctx.report, + panel_code=ctx.panel_code, + logs=ctx.serialize_logs(), + ok=True, + total_ms=total_ms, + ) diff --git a/backend/services/agents/extraction_agent.py b/backend/services/agents/extraction_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..a84e12a77e759a3b82464a7dce91a174e4e3a1c4 --- /dev/null +++ b/backend/services/agents/extraction_agent.py @@ -0,0 +1,75 @@ +"""Extraction Agent — parses structured findings from raw OCR text.""" +from __future__ import annotations +import re +import json +from .base import AgentBase, AgentContext, _SoftDegradation + + +class ExtractionAgent(AgentBase): + name = "extraction_agent" + max_retries = 2 + + def __init__(self, groq_client, render_prompt_fn): + self._groq = groq_client + self._render = render_prompt_fn + + def _execute(self, ctx: AgentContext) -> None: + from services.agent._validators import is_valid_test, is_impossible_value, get_status + + # Step 1: regex (fast, works for structured English reports) + pattern = r"([a-zA-Z][a-zA-Z\s#%]{2,})\s+(\d+\.?\d*)\s+([\d\.]+\s*-\s*[\d\.]+)\s*([a-zA-Z0-9^/]+)?" + raw = [f for f in re.findall(pattern, ctx.raw_text) if is_valid_test(f[0])] + + # Step 2: LLM fallback for Arabic/unstructured reports + if len(raw) < 2: + raw = self._llm_extract(ctx.raw_text) + + ctx.findings_raw = raw + + # Step 3: validate + normalize + findings = [] + for f in raw: + name = str(f[0]).strip() + value = str(f[1]) + if not is_valid_test(name): + continue + if is_impossible_value(name, value): + continue + findings.append({ + "name": name, + "value": value, + "range": str(f[2]), + "unit": str(f[3]) if len(f) > 3 else "", + "status": get_status(value, f[2], name=name), + }) + + ctx.findings = findings + ctx.memory.remember("finding_count", len(findings)) + + if not findings: + raise _SoftDegradation("No valid findings extracted") + + def _llm_extract(self, text: str) -> list: + prompt = self._render("extraction_template", LAB_TEXT=text[:6000]) or ( + "أنت خبير في تحليل التقارير الطبية المختبرية.\n" + "أرجع JSON array فقط. إذا لم تجد فحوصات مختبرية أرجع []\n" + '[{"name":"...","value":"...","range":"...","unit":"..."}]\n' + f"النص:\n{text[:6000]}" + ) + resp = self._groq.chat.completions.create( + model="llama-3.3-70b-versatile", + messages=[{"role": "user", "content": prompt}], + temperature=0.1, max_tokens=1500, + ) + raw = resp.choices[0].message.content.strip().replace("```json", "").replace("```", "").strip() + if "[" not in raw: + return [] + raw = raw[raw.index("["):raw.rindex("]") + 1] + extracted = json.loads(raw) + return [ + (f.get("name", ""), f.get("value", ""), f.get("range", ""), f.get("unit", "")) + for f in extracted if isinstance(f, dict) and f.get("name") and f.get("value") + ] + + def _on_failure(self, ctx: AgentContext, exc: Exception) -> None: + ctx.findings = [] diff --git a/backend/services/agents/ocr_agent.py b/backend/services/agents/ocr_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..ed8337b35bcd362417b72df8f2d7b512ada0a9f7 --- /dev/null +++ b/backend/services/agents/ocr_agent.py @@ -0,0 +1,50 @@ +"""OCR Agent — extracts raw text from PDF or image files.""" +from __future__ import annotations +import fitz +from .base import AgentBase, AgentContext, _SoftDegradation + + +class OCRAgent(AgentBase): + name = "ocr_agent" + max_retries = 2 + + def __init__(self, reader, vision_key: str = ""): + self._reader = reader + self._vision_key = vision_key + + def _execute(self, ctx: AgentContext) -> None: + if ctx.file_type == "pdf": + ctx.raw_text = self._ocr_pdf(ctx.file_bytes) + else: + ctx.raw_text = self._ocr_image(ctx.file_bytes) + + if len(ctx.raw_text.strip()) < 50: + raise _SoftDegradation(f"Very short OCR output: {len(ctx.raw_text)} chars") + + ctx.memory.remember("text_length", len(ctx.raw_text)) + + def _ocr_pdf(self, data: bytes) -> str: + from services.agent._ocr_helpers import preprocess_image + doc = fitz.open(stream=data, filetype="pdf") + raw_text = "\n".join(p.get_text() for p in doc) + if len(raw_text.strip()) >= 200: + return raw_text + parts = [] + for page in doc: + pix = page.get_pixmap(dpi=200) + text = "\n".join(self._reader.readtext(preprocess_image(pix.tobytes("png")), detail=0)) + parts.append(text) + return "\n".join(parts) + + def _ocr_image(self, data: bytes) -> str: + from services.agent._ocr_helpers import preprocess_image, extract_text_google_vision + if self._vision_key: + try: + return extract_text_google_vision(data, self._vision_key) + except Exception as e: + import logging + logging.getLogger("tebyan.agents").warning("[ocr] Vision fallback: %s", e) + return "\n".join(self._reader.readtext(preprocess_image(data), detail=0)) + + def _on_failure(self, ctx: AgentContext, exc: Exception) -> None: + ctx.raw_text = "" diff --git a/backend/services/agents/reasoning_agent.py b/backend/services/agents/reasoning_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..d32c6ee6061e31d69b9b5aa585b846f40a179abe --- /dev/null +++ b/backend/services/agents/reasoning_agent.py @@ -0,0 +1,119 @@ +""" +Medical Reasoning Agent — retrieves RAG context + generates structured report. +Implements Chain-of-Thought via panel-specific prompts. +""" +from __future__ import annotations +import json +from .base import AgentBase, AgentContext + + +_TYPE_HINTS = { + "دم شامل": "CBC complete blood count hemoglobin WBC RBC platelets", + "سكر وكوليسترول": "glucose HbA1c cholesterol LDL HDL triglycerides", + "كلى وكبد": "creatinine BUN urea ALT AST bilirubin GFR albumin", + "هرمونات": "TSH T3 T4 testosterone estradiol FSH LH prolactin", + "شامل": "clinical pathology lab results blood tests", +} + +_PANEL_PROMPTS = { + "cbc": "templates/cbc_analysis_prompt", + "thyroid": "templates/thyroid_analysis_prompt", + "liver": "templates/liver_analysis_prompt", + "kidney": "templates/kidney_analysis_prompt", + "diabetes": "templates/diabetes_analysis_prompt", + "lipid": "templates/lipid_analysis_prompt", +} + +_JSON_FORMAT = ( + '{"تقييم_عام":"جملتان عن الحالة",' + '"قيم_غير_طبيعية":[{"اسم_الفحص":"","النتيجة":"","المعدل_الطبيعي":"","الحالة":"","الشرح":"","المرجع":""}],' + '"توصيات":[{"category":"","tips":["",""]}]}' +) + + +class MedicalReasoningAgent(AgentBase): + name = "reasoning_agent" + max_retries = 2 + + def __init__(self, groq_client, retriever, kb, render_prompt_fn, retrieval_config_cls): + self._groq = groq_client + self._retriever = retriever + self._kb = kb + self._render = render_prompt_fn + self._cfg_cls = retrieval_config_cls + + def _execute(self, ctx: AgentContext) -> None: + self._retrieve(ctx) + self._analyze(ctx) + + def _retrieve(self, ctx: AgentContext) -> None: + from services.rag.context_builder import build_analysis_context + from services.cache import rag_cache, rag_cache_key + + hint = _TYPE_HINTS.get(ctx.analysis_type, _TYPE_HINTS["شامل"]) + q = f"{hint}: " + ", ".join(f["name"] for f in ctx.findings) + + cache_key = rag_cache_key(q, ctx.panel_code) + cached = rag_cache.get(cache_key) + if cached: + ctx.rag_context, ctx.rag_confidence, ctx.panel_context = cached + return + + rag_results, conf = self._retriever.retrieve( + q, + self._cfg_cls(k=10, use_multi_query=True, + topic_type="lab_test" if ctx.panel_code else None), + ) + panel_ctx = self._kb.build_panel_context(ctx.panel_code) if ctx.panel_code else "" + context = build_analysis_context(rag_results, panel_context=panel_ctx) + + ctx.rag_context, ctx.rag_confidence, ctx.panel_context = context, conf, panel_ctx + if context: + rag_cache.set(cache_key, (context, conf, panel_ctx), ttl=300) + + def _analyze(self, ctx: AgentContext) -> None: + prompt_name = _PANEL_PROMPTS.get(ctx.panel_code, "system_analysis") + prompt = self._render( + prompt_name, + FINDINGS=json.dumps(ctx.findings, ensure_ascii=False), + RAG_CONTEXT=ctx.rag_context or "لا توجد مراجع", + PATIENT_CONTEXT="", + SYSTEM_FORMAT=_JSON_FORMAT, + ) or ( + f"أنت طبيب مختبر خبير. أرجع JSON فقط — ابدأ بـ {{.\n" + f"النتائج: {json.dumps(ctx.findings, ensure_ascii=False)}\n" + f"المراجع: {ctx.rag_context or 'لا توجد مراجع'}\n\n{_JSON_FORMAT}" + ) + + resp = self._groq.chat.completions.create( + model="llama-3.3-70b-versatile", + messages=[{"role": "user", "content": prompt}], + temperature=0.3, max_tokens=2048, + ) + raw = resp.choices[0].message.content.strip().replace("```json", "").replace("```", "").strip() + if "{" in raw: + raw = raw[raw.index("{"):raw.rindex("}") + 1] + rd = json.loads(raw) + + ctx.report_raw = rd + ctx.report = { + "general": rd.get("تقييم_عام", ""), + "abnormal_details": rd.get("قيم_غير_طبيعية", []), + "tips_categorized": rd.get("توصيات", []), + "tips": rd.get("نصائح", []), + "rag_confidence": ctx.rag_confidence, + } + + def _on_failure(self, ctx: AgentContext, exc: Exception) -> None: + abnormal = [f for f in ctx.findings if f.get("status") != "normal"] + ctx.report = { + "general": "القيم التي تحتاج انتباهاً: " + "، ".join(f["name"] for f in abnormal[:3]) if abnormal else "جميع القيم ضمن المعدل الطبيعي ✓", + "abnormal_details": [ + {"اسم_الفحص": f["name"], "النتيجة": f["value"], "المعدل_الطبيعي": f["range"], + "الحالة": "مرتفع" if f["status"] == "high" else "منخفض", + "الشرح": f"قيمة {'مرتفعة' if f['status'] == 'high' else 'منخفضة'} عن المعدل.", + "المرجع": "لا يوجد"} + for f in abnormal + ], + "tips": [], "rag_confidence": ctx.rag_confidence, + } diff --git a/backend/services/agents/safety_agent.py b/backend/services/agents/safety_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..90d2f9986c319efa1dcd291f218cfe72d1c7afe2 --- /dev/null +++ b/backend/services/agents/safety_agent.py @@ -0,0 +1,19 @@ +"""Safety Agent — PDPL-compliant output filtering + disclaimer injection.""" +from __future__ import annotations +from .base import AgentBase, AgentContext + + +class SafetyAgent(AgentBase): + name = "safety_agent" + max_retries = 1 + + def _execute(self, ctx: AgentContext) -> None: + from services.safety import filter_analysis_report + ctx.report = filter_analysis_report(ctx.report) + ctx.memory.remember("safety_applied", True) + + def _on_failure(self, ctx: AgentContext, exc: Exception) -> None: + # Safety must not crash the pipeline — log and continue + from services.safety import DISCLAIMER_AR + if ctx.report.get("general"): + ctx.report["general"] = ctx.report["general"] + DISCLAIMER_AR diff --git a/backend/services/cache.py b/backend/services/cache.py new file mode 100644 index 0000000000000000000000000000000000000000..73c4378e9b2647d2bd688d35d070d511bd9b1fe7 --- /dev/null +++ b/backend/services/cache.py @@ -0,0 +1,135 @@ +""" +In-memory TTL cache for expensive operations (RAG retrieval, embeddings). +LRU + TTL strategy. Thread-safe. +Replace with Redis when deploying multi-process. + +Usage: + cache = TTLCache() + result = cache.get("rag:some_query") + if result is None: + result = expensive_call(...) + cache.set("rag:some_query", result, ttl=300) +""" +from __future__ import annotations + +import re +import time +import threading +import hashlib +import json +from collections import OrderedDict +from typing import Any + + +# ── Arabic normalization for consistent cache keys ───────────────────────── + +_ARABIC_NORM = str.maketrans( + "أإآاٱ" "ىي" "ة" "ؤئء", + "aaaaa" "يي" "ه" "ووء", +) +_WHITESPACE = re.compile(r"\s+") + + +def _normalize(text: str) -> str: + """Normalize Arabic text for cache key stability.""" + text = text.strip().lower() + text = text.translate(_ARABIC_NORM) + text = _WHITESPACE.sub(" ", text) + return text + + +# ── Cache implementation ─────────────────────────────────────────────────── + +class TTLCache: + """ + LRU cache with per-entry TTL and observable metrics. + max_size: maximum number of entries. + default_ttl: seconds before an entry expires. + """ + + def __init__(self, max_size: int = 512, default_ttl: int = 300) -> None: + self._store: OrderedDict[str, tuple[Any, float]] = OrderedDict() + self._lock = threading.Lock() + self._max = max_size + self._ttl = default_ttl + self._hits = 0 + self._misses = 0 + self._evictions = 0 + + def _is_expired(self, expires_at: float) -> bool: + return time.monotonic() > expires_at + + def get(self, key: str) -> Any | None: + with self._lock: + if key not in self._store: + self._misses += 1 + return None + value, expires_at = self._store[key] + if self._is_expired(expires_at): + del self._store[key] + self._misses += 1 + return None + self._store.move_to_end(key) + self._hits += 1 + return value + + def set(self, key: str, value: Any, ttl: int | None = None) -> None: + with self._lock: + expires_at = time.monotonic() + (ttl if ttl is not None else self._ttl) + if key in self._store: + self._store.move_to_end(key) + self._store[key] = (value, expires_at) + if len(self._store) > self._max: + self._store.popitem(last=False) + self._evictions += 1 + + def delete(self, key: str) -> None: + with self._lock: + self._store.pop(key, None) + + def clear(self) -> None: + with self._lock: + self._store.clear() + + def stats(self) -> dict: + with self._lock: + now = time.monotonic() + alive = sum(1 for _, (_, exp) in self._store.items() if exp > now) + total = self._hits + self._misses + return { + "size": len(self._store), + "alive": alive, + "max": self._max, + "hits": self._hits, + "misses": self._misses, + "evictions": self._evictions, + "hit_rate": round(self._hits / total, 3) if total else 0.0, + } + + +# ── Specialized caches ───────────────────────────────────────────────────── + +# RAG context results (expensive: embedding + pgvector + Cohere) +rag_cache = TTLCache(max_size=256, default_ttl=300) # 5-minute TTL + +# Chat medical-query intent check +intent_cache = TTLCache(max_size=1024, default_ttl=600) # 10-minute TTL + +# Global KB search results (pgvector hit — ~5 s each) +search_cache = TTLCache(max_size=512, default_ttl=120) # 2-minute TTL + + +def rag_cache_key(query: str, panel: str = "", topic_type: str = "") -> str: + """ + Deterministic, normalized cache key for a RAG retrieval call. + Normalization: Arabic chars unified, whitespace collapsed, lowercased. + """ + norm_q = _normalize(query[:300]) + raw = json.dumps({"q": norm_q, "panel": panel.lower(), "tt": topic_type.lower()}, + sort_keys=True, ensure_ascii=False) + return "rag:" + hashlib.sha256(raw.encode()).hexdigest()[:16] + + +def intent_cache_key(query: str) -> str: + norm_q = _normalize(query[:200]) + return "intent:" + hashlib.sha256(norm_q.encode()).hexdigest()[:16] diff --git a/backend/services/classifier.py b/backend/services/classifier.py new file mode 100644 index 0000000000000000000000000000000000000000..cfc251c3ed99c14fa794aa1c63fd24faf5e69e41 --- /dev/null +++ b/backend/services/classifier.py @@ -0,0 +1,135 @@ +""" +Medical Report Classifier — rule-based panel detection from extracted findings. +Works on structured findings list (not raw text) for higher precision. +Supports multi-panel detection (e.g., CBC + Thyroid in the same report). +""" +from __future__ import annotations +from dataclasses import dataclass + +# ── Panel keyword signatures ───────────────────────────────────────────────── +# Each panel has primary markers (weight=2) and secondary markers (weight=1) + +_PANEL_SIGNATURES: dict[str, dict] = { + "cbc": { + "primary": {"hemoglobin", "hgb", "wbc", "rbc", "platelets", "plt", + "هيموجلوبين", "كريات بيضاء", "كريات حمراء", "صفائح"}, + "secondary": {"hematocrit", "hct", "mcv", "mch", "mchc", "neutrophils", + "lymphocytes", "monocytes", "eosinophils", "basophils", + "هيماتوكريت", "خضاب", "cbc"}, + "min_score": 2, + }, + "thyroid": { + "primary": {"tsh", "ft4", "ft3", "free t4", "free t3", + "درقية", "غدة درقية"}, + "secondary": {"t3", "t4", "anti-tpo", "anti tpo", "thyroglobulin", + "tpo", "hashimoto", "هاشيموتو"}, + "min_score": 2, + }, + "liver": { + "primary": {"alt", "ast", "sgpt", "sgot", "alp", "ggt", + "bilirubin", "albumin", + "كبد", "بيليروبين", "البومين"}, + "secondary": {"direct bilirubin", "indirect bilirubin", "total protein", + "pt", "inr", "lft", "alk phos", + "انزيمات", "بروتين كلي"}, + "min_score": 2, + }, + "kidney": { + "primary": {"creatinine", "egfr", "gfr", "bun", "urea", + "كلى", "كرياتينين"}, + "secondary": {"uric acid", "cystatin", "kft", "microalbumin", + "يوريا", "حمض البول"}, + "min_score": 2, + }, + "diabetes": { + "primary": {"glucose", "hba1c", "a1c", "fasting glucose", + "سكر", "سكر صيام", "سكر تراكمي"}, + "secondary": {"insulin", "homa", "homa-ir", "c-peptide", + "postprandial glucose", "random glucose", + "انسولين", "مقاومة الانسولين"}, + "min_score": 2, + }, + "lipid": { + "primary": {"cholesterol", "ldl", "hdl", "triglycerides", + "كوليسترول", "دهون ثلاثية"}, + "secondary": {"vldl", "non-hdl", "lipoprotein", "lipid panel", + "دهنيات", "دهون"}, + "min_score": 2, + }, +} + + +@dataclass +class ClassificationResult: + primary_panel: str # best-match panel code + all_panels: list[str] # all panels detected (sorted by score) + scores: dict[str, float] # panel -> score + confidence: str # "high" | "medium" | "low" + + +def _normalize(name: str) -> str: + return name.lower().strip().replace("(", "").replace(")", "").replace("-", " ") + + +def classify_report(findings: list[dict]) -> ClassificationResult: + """ + Classify a report into panel types based on extracted findings. + + Args: + findings: list of dicts with at least {"name": str} + + Returns: + ClassificationResult with primary_panel and confidence + """ + names = {_normalize(f.get("name", "")) for f in findings} + scores: dict[str, float] = {} + + for panel, sig in _PANEL_SIGNATURES.items(): + score = 0.0 + for name in names: + for kw in sig["primary"]: + if kw in name or name in kw: + score += 2 + break + else: + for kw in sig["secondary"]: + if kw in name or name in kw: + score += 1 + break + scores[panel] = score + + detected = [ + panel for panel, score in scores.items() + if score >= _PANEL_SIGNATURES[panel]["min_score"] + ] + detected.sort(key=lambda p: scores[p], reverse=True) + + if not detected: + primary = "" + confidence = "low" + else: + primary = detected[0] + top_score = scores[primary] + if top_score >= 6: + confidence = "high" + elif top_score >= 3: + confidence = "medium" + else: + confidence = "low" + + return ClassificationResult( + primary_panel=primary, + all_panels=detected, + scores=scores, + confidence=confidence, + ) + + +def classify_from_text(text: str) -> str: + """ + Lightweight text-based classification for search queries. + Returns panel code or empty string. + Wraps existing detect_panel logic for backward compatibility. + """ + from services.search.query_parser import detect_panel + return detect_panel(text) or "" diff --git a/backend/services/llm/__init__.py b/backend/services/llm/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..81f826b9f16dc6eec26f0fde4a85e156c11251ef --- /dev/null +++ b/backend/services/llm/__init__.py @@ -0,0 +1,3 @@ +from .router import LLMRouter, get_router + +__all__ = ["LLMRouter", "get_router"] diff --git a/backend/services/llm/router.py b/backend/services/llm/router.py new file mode 100644 index 0000000000000000000000000000000000000000..6cbe11ad09cc39aadade3a329a5f59e7a55b9542 --- /dev/null +++ b/backend/services/llm/router.py @@ -0,0 +1,242 @@ +""" +LLM Model Router — unified interface over Groq and HuggingFace Inference API. + +Supported providers: + groq — default (llama-3.3-70b / llama-3.1-8b) + hf — HuggingFace Inference API (Jais-13b, AceGPT, CAMeL models) + +Usage: + router = get_router() + text = router.generate(messages, model_hint="arabic") + stream = router.stream(messages) + +Model hints: + "analysis" → large model (llama-3.3-70b or Jais-13b if arabic_mode) + "chat" → fast model (llama-3.1-8b or Jais-7b if arabic_mode) + "arabic" → force Arabic-specialized model when available +""" +from __future__ import annotations + +import os +import logging +from abc import ABC, abstractmethod +from functools import lru_cache +from typing import Generator + +log = logging.getLogger("tebyan.llm") + + +# ── Abstract base ────────────────────────────────────────────────────────── + +class LLMProvider(ABC): + @abstractmethod + def generate(self, messages: list[dict], max_tokens: int = 2048, temperature: float = 0.3) -> str: ... + + @abstractmethod + def stream(self, messages: list[dict], max_tokens: int = 1024, temperature: float = 0.5) -> Generator[str, None, None]: ... + + @property + @abstractmethod + def name(self) -> str: ... + + +# ── Groq provider ────────────────────────────────────────────────────────── + +class GroqProvider(LLMProvider): + _MODEL_ANALYSIS = "llama-3.3-70b-versatile" + _MODEL_CHAT = "llama-3.1-8b-instant" + + def __init__(self, api_key: str, model_hint: str = "analysis"): + from groq import Groq + self._client = Groq(api_key=api_key) + self._model_hint = model_hint + + @property + def name(self) -> str: + return "groq" + + def _model(self, hint: str = "") -> str: + h = hint or self._model_hint + return self._MODEL_CHAT if h == "chat" else self._MODEL_ANALYSIS + + def generate(self, messages: list[dict], max_tokens: int = 2048, temperature: float = 0.3, + model_hint: str = "") -> str: + resp = self._client.chat.completions.create( + model=self._model(model_hint), + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + ) + return resp.choices[0].message.content + + def stream(self, messages: list[dict], max_tokens: int = 1024, temperature: float = 0.5, + model_hint: str = "") -> Generator[str, None, None]: + stream = self._client.chat.completions.create( + model=self._model(model_hint or "chat"), + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + stream=True, + ) + for chunk in stream: + delta = chunk.choices[0].delta.content + if delta: + yield delta + + +# ── HuggingFace Inference API provider ──────────────────────────────────── + +class HuggingFaceProvider(LLMProvider): + """ + Wraps HuggingFace Inference API for Arabic-specialized models. + Supported models: + - core42/jais-13b-chat (Arabic-English bilingual) + - core42/jais-adapted-7b-chat (smaller/faster) + - ALLaM-7B-Instruct-preview (Arabic LLM from SDAIA — when available) + """ + + _DEFAULT_MODEL = "core42/jais-13b-chat" + _FAST_MODEL = "core42/jais-adapted-7b-chat" + _HF_INFERENCE = "https://api-inference.huggingface.co/models" + + def __init__(self, api_key: str, model_hint: str = "analysis"): + self._key = api_key + self._model_hint = model_hint + + @property + def name(self) -> str: + return "huggingface" + + def _model(self, hint: str = "") -> str: + h = hint or self._model_hint + return self._FAST_MODEL if h == "chat" else self._DEFAULT_MODEL + + def _headers(self) -> dict: + return {"Authorization": f"Bearer {self._key}", "Content-Type": "application/json"} + + def _messages_to_text(self, messages: list[dict]) -> str: + parts = [] + for m in messages: + role = m.get("role", "user") + content = m.get("content", "") + if role == "system": + parts.append(f"[INST] <>\n{content}\n<>\n\n") + elif role == "user": + parts.append(f"{content} [/INST]") + else: + parts.append(f"{content} [INST] ") + return "".join(parts) + + def generate(self, messages: list[dict], max_tokens: int = 2048, temperature: float = 0.3, + model_hint: str = "") -> str: + import requests + model = self._model(model_hint) + payload = { + "inputs": self._messages_to_text(messages), + "parameters": { + "max_new_tokens": min(max_tokens, 2048), + "temperature": max(temperature, 0.01), + "do_sample": temperature > 0.01, + "return_full_text": False, + }, + } + url = f"{self._HF_INFERENCE}/{model}" + try: + r = requests.post(url, headers=self._headers(), json=payload, timeout=60) + r.raise_for_status() + data = r.json() + if isinstance(data, list) and data: + return data[0].get("generated_text", "") + return str(data) + except Exception as e: + log.error("[HF] generate failed for %s: %s", model, e) + raise + + def stream(self, messages: list[dict], max_tokens: int = 1024, temperature: float = 0.5, + model_hint: str = "") -> Generator[str, None, None]: + # HF Inference API doesn't support streaming for all models; + # generate fully then yield the result as one chunk + text = self.generate(messages, max_tokens=max_tokens, temperature=temperature, model_hint=model_hint) + yield text + + +# ── Router ───────────────────────────────────────────────────────────────── + +class LLMRouter: + """ + Routes LLM calls to the appropriate provider. + Falls back to Groq when the preferred provider fails. + """ + + def __init__(self, primary: LLMProvider, fallback: LLMProvider | None = None): + self._primary = primary + self._fallback = fallback + + def generate(self, messages: list[dict], max_tokens: int = 2048, temperature: float = 0.3, + model_hint: str = "") -> str: + try: + result = self._primary.generate(messages, max_tokens=max_tokens, + temperature=temperature, model_hint=model_hint) + log.debug("[router] generate via %s | tokens≤%d", self._primary.name, max_tokens) + return result + except Exception as e: + if self._fallback: + log.warning("[router] primary %s failed (%s) — fallback to %s", + self._primary.name, e, self._fallback.name) + return self._fallback.generate(messages, max_tokens=max_tokens, + temperature=temperature, model_hint=model_hint) + raise + + def stream(self, messages: list[dict], max_tokens: int = 1024, temperature: float = 0.5, + model_hint: str = "") -> Generator[str, None, None]: + try: + yield from self._primary.stream(messages, max_tokens=max_tokens, + temperature=temperature, model_hint=model_hint) + except Exception as e: + if self._fallback: + log.warning("[router] stream primary failed (%s) — fallback", e) + yield from self._fallback.stream(messages, max_tokens=max_tokens, + temperature=temperature, model_hint=model_hint) + else: + raise + + @property + def provider_name(self) -> str: + return self._primary.name + + +@lru_cache(maxsize=1) +def get_router() -> LLMRouter: + """ + Build the LLMRouter singleton from environment config. + + Env vars: + GROQ_API_KEY — required + HF_API_KEY — optional; enables HuggingFace provider + LLM_PROVIDER — "groq" (default) | "hf" | "hf_with_groq_fallback" + ARABIC_MODEL_MODE — "1" to prefer Arabic-specialized models + """ + groq_key = os.getenv("GROQ_API_KEY", "") + hf_key = os.getenv("HF_API_KEY", "") + provider = os.getenv("LLM_PROVIDER", "groq").lower() + arabic_mode = os.getenv("ARABIC_MODEL_MODE", "0") == "1" + + groq_hint = "arabic" if arabic_mode else "analysis" + groq_p = GroqProvider(groq_key, model_hint=groq_hint) if groq_key else None + hf_p = HuggingFaceProvider(hf_key, model_hint=groq_hint) if hf_key else None + + if provider == "hf" and hf_p: + primary, fallback = hf_p, groq_p + elif provider == "hf_with_groq_fallback" and hf_p and groq_p: + primary, fallback = hf_p, groq_p + else: + primary = groq_p or hf_p + fallback = hf_p if (provider != "groq" and hf_p and primary is not hf_p) else None + + if not primary: + raise RuntimeError("No LLM provider configured. Set GROQ_API_KEY or HF_API_KEY.") + + log.info("[router] primary=%s fallback=%s arabic_mode=%s", + primary.name, fallback.name if fallback else "none", arabic_mode) + + return LLMRouter(primary=primary, fallback=fallback) diff --git a/backend/services/rag/__init__.py b/backend/services/rag/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c081fc271f4f43e98448a81c6e6cd97f076ca548 --- /dev/null +++ b/backend/services/rag/__init__.py @@ -0,0 +1,11 @@ +from .retriever import Retriever, RetrievalConfig +from .context_builder import build_context, confidence_label +from .ranking import rank, bm25_rerank, cohere_rerank +from .grounding import extract_citations, format_citations_arabic + +__all__ = [ + "Retriever", "RetrievalConfig", + "build_context", "confidence_label", + "rank", "bm25_rerank", "cohere_rerank", + "extract_citations", "format_citations_arabic", +] diff --git a/backend/services/rag/context_builder.py b/backend/services/rag/context_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..8f363867720d1b63f7dad60dfe98b97fc2973dfd --- /dev/null +++ b/backend/services/rag/context_builder.py @@ -0,0 +1,75 @@ +""" +Context builder — assembles retrieved SearchResults into a prompt-ready string. +Manages token budget and adds source attribution. +""" +from __future__ import annotations + +from ..search.semantic_search import SearchResult + +_SEPARATOR = "\n\n---\n\n" +_CHARS_PER_TOKEN = 4 # rough estimate for Arabic+English mixed text + + +def build_context( + results: list[SearchResult], + max_tokens: int = 2000, + include_source: bool = True, +) -> str: + """ + Assemble top results into a context string. + Stays within max_tokens budget (estimated by char count / 4). + """ + if not results: + return "" + + budget = max_tokens * _CHARS_PER_TOKEN + parts: list[str] = [] + used = 0 + + for r in results: + chunk = r.content.strip() + if not chunk: + continue + if include_source and r.source: + chunk = f"[{r.source}]\n{chunk}" + chunk_len = len(chunk) + if used + chunk_len > budget: + break + parts.append(chunk) + used += chunk_len + len(_SEPARATOR) + + return _SEPARATOR.join(parts) + + +def confidence_label(results: list[SearchResult]) -> str: + """ + Return Arabic confidence label based on top-5 scores. + عالية / متوسطة / منخفضة / لا يوجد + """ + if not results: + return "لا يوجد" + top = sorted([r.score for r in results], reverse=True)[:5] + avg = sum(top) / len(top) + if avg > 0.70: + return "عالية" + if avg > 0.45: + return "متوسطة" + return "منخفضة" + + +def build_analysis_context( + results: list[SearchResult], + panel_context: str = "", + max_tokens: int = 2500, +) -> str: + """ + Build context for the analysis endpoint. + Prepends panel reference ranges before retrieved docs. + """ + parts: list[str] = [] + if panel_context: + parts.append(f"[مراجع اللوحة الطبية]\n{panel_context}") + rag = build_context(results, max_tokens=max_tokens - len(panel_context) // _CHARS_PER_TOKEN) + if rag: + parts.append(rag) + return _SEPARATOR.join(parts) if parts else "" diff --git a/backend/services/rag/grounding.py b/backend/services/rag/grounding.py new file mode 100644 index 0000000000000000000000000000000000000000..9f3aa0e89dd0066c947fbd8699926f241dcb777a --- /dev/null +++ b/backend/services/rag/grounding.py @@ -0,0 +1,69 @@ +""" +Grounding — citation extraction and source attribution. +Converts SearchResults into citable source references. +""" +from __future__ import annotations +from dataclasses import dataclass + +from ..search.semantic_search import SearchResult + +_SOURCE_URLS: dict[str, str] = { + "MedlinePlus": "https://medlineplus.gov", + "TibyanLabs": "", +} + + +@dataclass +class Citation: + source: str + content_preview: str # first 120 chars + score: float + url: str | None = None + chunk_type: str | None = None + specialty: str | None = None + + +def extract_citations(results: list[SearchResult]) -> list[Citation]: + """Convert ranked SearchResults to deduplicated citations.""" + citations: list[Citation] = [] + seen_sources: set[str] = set() + + for r in results: + meta = r.metadata + source = r.source or meta.get("source", "Unknown") + url = meta.get("url") or _SOURCE_URLS.get(source) + + citations.append(Citation( + source=source, + content_preview=r.content[:120].strip(), + score=r.score, + url=url or None, + chunk_type=meta.get("chunk_type"), + specialty=meta.get("specialty"), + )) + seen_sources.add(source) + + return citations + + +def format_citations_arabic(citations: list[Citation]) -> str: + """Human-readable Arabic citation list for injection into prompts.""" + if not citations: + return "" + lines: list[str] = ["**المصادر:**"] + seen: set[str] = set() + for c in citations: + key = c.source + if key in seen: + continue + seen.add(key) + if c.url: + lines.append(f"- [{c.source}]({c.url})") + else: + lines.append(f"- {c.source}") + return "\n".join(lines) + + +def has_high_confidence_sources(citations: list[Citation]) -> bool: + """True if at least one citation has score >= 0.7.""" + return any(c.score >= 0.7 for c in citations) diff --git a/backend/services/rag/ranking.py b/backend/services/rag/ranking.py new file mode 100644 index 0000000000000000000000000000000000000000..e1dc01736a8937cd9fe093b63775a5b30aa66ea8 --- /dev/null +++ b/backend/services/rag/ranking.py @@ -0,0 +1,75 @@ +""" +Ranking — BM25 reranking and Cohere reranking. +Operates on SearchResult lists from SemanticSearchService. +""" +from __future__ import annotations +import logging +import re + +from rank_bm25 import BM25Okapi + +log = logging.getLogger("tebyan.ranking") + +from ..search.semantic_search import SearchResult + + +def bm25_rerank( + results: list[SearchResult], + query: str, + top_n: int = 5, +) -> list[SearchResult]: + """Rerank using BM25 combined with vector scores (0.3 weight).""" + if len(results) <= 1: + return results[:top_n] + + tokenize = lambda t: re.findall(r"\w+", t.lower()) + corpus = [tokenize(r.content) for r in results] + bm25 = BM25Okapi(corpus) + bm_scores = bm25.get_scores(tokenize(query)) + + mx = max(bm_scores) if max(bm_scores) > 0 else 1.0 + for r, bs in zip(results, bm_scores): + r.score = r.score + (bs / mx) * 0.3 # fuse: vector 1.0 + BM25 0.3 + + results.sort(key=lambda r: r.score, reverse=True) + return results[:top_n] + + +def cohere_rerank( + co_client, + results: list[SearchResult], + query: str, + top_n: int = 5, +) -> list[SearchResult]: + """Rerank using Cohere rerank-v3.5. Falls back to BM25 on error.""" + if not co_client or len(results) <= 1: + return bm25_rerank(results, query, top_n) + try: + docs_text = [r.content for r in results] + resp = co_client.rerank( + model="rerank-v3.5", + query=query, + documents=docs_text, + top_n=top_n, + ) + reranked: list[SearchResult] = [] + for item in resp.results: + r = results[item.index] + r.score = float(item.relevance_score) + reranked.append(r) + return reranked + except Exception as e: + log.warning("Cohere rerank failed (%s) — BM25 fallback", e) + return bm25_rerank(results, query, top_n) + + +def rank( + results: list[SearchResult], + query: str, + co_client=None, + top_n: int = 5, +) -> list[SearchResult]: + """Main entry: use Cohere if client available, else BM25.""" + if co_client: + return cohere_rerank(co_client, results, query, top_n) + return bm25_rerank(results, query, top_n) diff --git a/backend/services/rag/retriever.py b/backend/services/rag/retriever.py new file mode 100644 index 0000000000000000000000000000000000000000..b0ae2f6f41d07f5ac5a11f92c0cc8e6f13266c59 --- /dev/null +++ b/backend/services/rag/retriever.py @@ -0,0 +1,134 @@ +""" +Retriever — full RAG retrieval pipeline. + +Pipeline: + 1. Parse query → detect panel/topic/language + 2. Expand to multiple queries (via query_parser + optional Groq) + 3. Vector search per query via SemanticSearchService + 4. Deduplicate + merge scores + 5. Filter by minimum score threshold + 6. Rank (Cohere or BM25) + 7. Return (results, confidence_label) +""" +from __future__ import annotations +import logging +from dataclasses import dataclass, field +from typing import Callable + +log = logging.getLogger("tebyan.retriever") + +from ..search.semantic_search import SemanticSearchService, SearchResult +from ..search.query_parser import parse_query +from .ranking import rank +from .context_builder import confidence_label + + +@dataclass +class RetrievalConfig: + k: int = 10 # candidates per query from vector search + top_n: int = 5 # final results after reranking + min_score: float = 0.4 # minimum relevance threshold + use_multi_query: bool = True # expand to multiple queries + topic_type: str | None = None # pgvector metadata filter + + +class Retriever: + """ + Orchestrates the full retrieval pipeline. + Injected with SemanticSearchService and optional Cohere/Groq clients. + """ + + def __init__( + self, + search_service: SemanticSearchService, + co_client=None, + query_expander: Callable[[str], list[str]] | None = None, + ) -> None: + self._search = search_service + self._co = co_client + self._expander = query_expander # e.g. lambda q: generate_search_queries(groq, q) + + # ── Query expansion ──────────────────────────────────────────────────── + + def _expand_queries(self, query: str) -> list[str]: + parsed = parse_query(query) + queries = list(parsed.search_expansions) # already includes original + + if self._expander and len(queries) < 3: + try: + groq_queries = self._expander(query) + for q in groq_queries: + if q not in queries: + queries.append(q) + except Exception as e: + log.warning("query expansion failed: %s", e) + + return queries[:4] # cap to avoid too many API calls + + # ── Main retrieval ───────────────────────────────────────────────────── + + def retrieve( + self, + query: str, + config: RetrievalConfig | None = None, + ) -> tuple[list[SearchResult], str]: + """ + Returns (ranked_results, confidence_label_ar). + confidence_label_ar: 'عالية' | 'متوسطة' | 'منخفضة' | 'لا يوجد' + """ + cfg = config or RetrievalConfig() + + queries = self._expand_queries(query) if cfg.use_multi_query else [query] + seen: dict[str, SearchResult] = {} + + for q in queries: + for r in self._search.search(q, k=cfg.k, topic_type=cfg.topic_type): + key = r.content[:80] + if key not in seen or seen[key].score < r.score: + seen[key] = r + + filtered = [r for r in seen.values() if r.score >= cfg.min_score] + if not filtered: + log.warning( + "RAG no results | query=%r threshold=%.2f candidates=%d", + query[:60], cfg.min_score, len(seen), + ) + return [], "لا يوجد" + + ranked = rank(filtered, query, co_client=self._co, top_n=cfg.top_n) + conf = confidence_label(ranked) + + top_scores = [round(r.score, 3) for r in ranked[:3]] + sources = list({r.source for r in ranked}) + log.info( + "RAG retrieve | query=%r queries=%d candidates=%d top=%d " + "conf=%s scores=%s sources=%s", + query[:60], len(queries), len(filtered), len(ranked), + conf, top_scores, sources, + ) + return ranked, conf + + # ── Lightweight retrieval (no multi-query, no Cohere) ────────────────── + + def retrieve_fast( + self, + query: str, + k: int = 5, + top_n: int = 3, + ) -> list[SearchResult]: + """ + Optimized for chat endpoint — no query expansion, no Cohere rerank. + Reduces latency by ~4 seconds compared to full pipeline. + """ + results = self._search.search(query, k=k) + filtered = [r for r in results if r.score >= 0.35] + if not filtered: + log.debug("RAG fast retrieve | query=%r → 0 results above 0.35", query[:60]) + return [] + ranked = rank(filtered, query, co_client=None, top_n=top_n) + log.debug( + "RAG fast retrieve | query=%r candidates=%d top=%d scores=%s", + query[:60], len(filtered), len(ranked), + [round(r.score, 3) for r in ranked], + ) + return ranked diff --git a/backend/services/ratelimit.py b/backend/services/ratelimit.py new file mode 100644 index 0000000000000000000000000000000000000000..d99f2e13b8cafa5b6675b61df51405f47c35e617 --- /dev/null +++ b/backend/services/ratelimit.py @@ -0,0 +1,158 @@ +""" +Rate limiter for FastAPI — Redis-backed in production, in-memory fallback. + +Redis mode: works correctly across multiple uvicorn workers. +Memory mode: single-process only (development / single-worker deploys). + +Env vars: + REDIS_URL — e.g. "redis://redis:6379/0" (enables Redis mode) + If unset, falls back to in-memory sliding window. +""" +from __future__ import annotations + +import os +import time +import logging +import threading +from collections import defaultdict, deque + +from fastapi import Request, HTTPException + +log = logging.getLogger("tebyan.ratelimit") + +_REDIS_URL = os.getenv("REDIS_URL", "") +_IS_PROD = os.getenv("ENVIRONMENT", "development") == "production" +_TRUSTED_PROXY = os.getenv("TRUSTED_PROXY_IPS", "").split(",") + + +def _extract_ip(request: Request) -> str: + """Return real client IP. Only trust X-Forwarded-For in production behind nginx.""" + if _IS_PROD: + forwarded = request.headers.get("X-Forwarded-For", "") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +# ── Redis-backed limiter ──────────────────────────────────────────────────── + +class _RedisLimiter: + """ + Sliding-window rate limiter backed by Redis sorted sets. + Works correctly with multiple uvicorn workers. + """ + + def __init__(self, redis_url: str) -> None: + import redis as _redis + self._r = _redis.from_url(redis_url, decode_responses=True, socket_connect_timeout=2) + self._r.ping() + log.info("[ratelimit] Redis mode | url=%s", redis_url.split("@")[-1]) + + def _key(self, request: Request, endpoint: str) -> str: + ip = _extract_ip(request) + return f"rl:{endpoint}:{ip}" + + async def check(self, request: Request, limit: int, window: int = 60, + endpoint: str = "") -> None: + key = self._key(request, endpoint or request.url.path) + now = time.time() + cutoff = now - window + + pipe = self._r.pipeline() + pipe.zremrangebyscore(key, "-inf", cutoff) + pipe.zcard(key) + pipe.zadd(key, {str(now): now}) + pipe.expire(key, window + 1) + _, count, *_ = pipe.execute() + + if count >= limit: + raise HTTPException( + status_code=429, + detail={ + "error": "rate_limit_exceeded", + "message": "لقد تجاوزت الحد المسموح به من الطلبات. يرجى الانتظار قليلاً.", + "retry_after": window, + }, + headers={"Retry-After": str(window)}, + ) + + +# ── In-memory limiter (fallback) ──────────────────────────────────────────── + +class _MemoryLimiter: + """Sliding-window per (IP, endpoint). Thread-safe. Single-process only.""" + + def __init__(self) -> None: + self._windows: dict[str, deque[float]] = defaultdict(deque) + self._lock = threading.Lock() + log.info("[ratelimit] memory mode (single-process)") + + def _key(self, request: Request, endpoint: str) -> str: + forwarded = request.headers.get("X-Forwarded-For") + ip = forwarded.split(",")[0].strip() if forwarded else ( + request.client.host if request.client else "unknown" + ) + return f"{ip}:{endpoint}" + + async def check(self, request: Request, limit: int, window: int = 60, + endpoint: str = "") -> None: + key = self._key(request, endpoint or request.url.path) + now = time.monotonic() + cutoff = now - window + + with self._lock: + dq = self._windows[key] + while dq and dq[0] < cutoff: + dq.popleft() + if len(dq) >= limit: + retry_after = int(window - (now - dq[0])) + 1 + raise HTTPException( + status_code=429, + detail={ + "error": "rate_limit_exceeded", + "message": "لقد تجاوزت الحد المسموح به من الطلبات. يرجى الانتظار قليلاً.", + "retry_after": retry_after, + }, + headers={"Retry-After": str(retry_after)}, + ) + dq.append(now) + + +# ── Singleton: Redis → fakeredis → memory ────────────────────────────────── + +def _build_limiter(): + if _REDIS_URL: + try: + return _RedisLimiter(_REDIS_URL) + except Exception as e: + log.warning("[ratelimit] Redis unavailable (%s) — trying fakeredis", e) + try: + import fakeredis + server = fakeredis.FakeServer() + fake_r = fakeredis.FakeRedis(server=server, decode_responses=True) + fake_r.ping() + limiter = _RedisLimiter.__new__(_RedisLimiter) + limiter._r = fake_r + log.info("[ratelimit] fakeredis mode (dev/single-process)") + return limiter + except Exception as fe: + log.warning("[ratelimit] fakeredis unavailable (%s) — memory fallback", fe) + return _MemoryLimiter() + + +_limiter = _build_limiter() + + +async def limit_analyze(request: Request) -> None: + """5 analysis requests per minute per IP.""" + await _limiter.check(request, limit=5, window=60, endpoint="analyze") + + +async def limit_chat(request: Request) -> None: + """30 chat requests per minute per IP.""" + await _limiter.check(request, limit=30, window=60, endpoint="chat") + + +async def limit_search(request: Request) -> None: + """60 search requests per minute per IP.""" + await _limiter.check(request, limit=60, window=60, endpoint="search") diff --git a/backend/services/risk/__init__.py b/backend/services/risk/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9d24899ba4d55f4ad2278e452d4005a80d97552a --- /dev/null +++ b/backend/services/risk/__init__.py @@ -0,0 +1,4 @@ +from .risk_engine import RiskEngine, RiskReport +from .feature_extractor import FeatureExtractor + +__all__ = ["RiskEngine", "RiskReport", "FeatureExtractor"] diff --git a/backend/services/risk/feature_extractor.py b/backend/services/risk/feature_extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..239eb2ef1f2a43cec352c437eccbc3df5540e08a --- /dev/null +++ b/backend/services/risk/feature_extractor.py @@ -0,0 +1,114 @@ +""" +Feature Extractor — converts a findings list into a normalized numeric feature vector +suitable for risk model inference (ML or rule-based). + +Each feature is named, typed, and bounded so models stay interpretable. +Missing values are encoded as NaN (→ median imputation in ML pipeline). +""" +from __future__ import annotations +import math +from dataclasses import dataclass, field + + +# ── Feature definitions ──────────────────────────────────────────────────── + +# Maps canonical feature name → (list of finding name substrings to search) +_FEATURE_MAP: dict[str, list[str]] = { + # Diabetes markers + "glucose": ["glucose", "سكر صيام", "fasting glucose", "blood sugar"], + "hba1c": ["hba1c", "a1c", "glycated", "سكر تراكمي"], + "insulin": ["insulin", "انسولين"], + "homa_ir": ["homa", "homa-ir", "مقاومة الانسولين"], + # Lipid panel + "cholesterol": ["cholesterol", "total cholesterol", "كوليسترول"], + "ldl": ["ldl", "low density"], + "hdl": ["hdl", "high density"], + "triglycerides": ["triglycerides", "tg", "دهون ثلاثية"], + # CBC + "hemoglobin": ["hemoglobin", "hgb", "هيموجلوبين"], + "hematocrit": ["hematocrit", "hct", "هيماتوكريت"], + "wbc": ["wbc", "white blood", "كريات بيضاء"], + "platelets": ["platelets", "plt", "صفائح"], + "rbc": ["rbc", "red blood", "كريات حمراء"], + "mcv": ["mcv"], + "mch": ["mch"], + # Kidney + "creatinine": ["creatinine", "كرياتينين"], + "bun": ["bun", "blood urea nitrogen", "يوريا"], + "uric_acid": ["uric acid", "حمض البول"], + "egfr": ["egfr", "gfr", "glomerular"], + # Liver + "alt": ["alt", "sgpt", "alanine"], + "ast": ["ast", "sgot", "aspartate"], + "alp": ["alp", "alkaline phosphatase"], + "ggt": ["ggt", "gamma"], + "bilirubin": ["bilirubin", "بيليروبين"], + "albumin": ["albumin", "البومين"], + # Thyroid + "tsh": ["tsh", "thyroid stimulating", "درقية"], + "ft4": ["ft4", "free t4", "free thyroxine"], + "ft3": ["ft3", "free t3"], + # Inflammation + "crp": ["crp", "c-reactive"], + "esr": ["esr", "erythrocyte sedimentation"], + # Vitamins / minerals + "vitamin_d": ["vitamin d", "25-oh", "فيتامين د"], + "vitamin_b12": ["vitamin b12", "b12", "cobalamin", "فيتامين ب"], + "ferritin": ["ferritin", "فيريتين"], + "iron": ["iron", "serum iron", "حديد"], + "calcium": ["calcium", "كالسيوم"], + "sodium": ["sodium", "na+", "صوديوم"], + "potassium": ["potassium", "k+", "بوتاسيوم"], + # Coagulation + "pt": ["pt", "prothrombin time"], + "inr": ["inr"], +} + + +@dataclass +class FeatureVector: + values: dict[str, float] = field(default_factory=dict) # name → numeric value or NaN + statuses: dict[str, str] = field(default_factory=dict) # name → "normal"|"high"|"low" + + def get(self, name: str) -> float: + return self.values.get(name, math.nan) + + def is_abnormal(self, name: str) -> bool: + return self.statuses.get(name, "normal") != "normal" + + def is_high(self, name: str) -> bool: + return self.statuses.get(name) == "high" + + def is_low(self, name: str) -> bool: + return self.statuses.get(name) == "low" + + def has(self, name: str) -> bool: + return not math.isnan(self.get(name)) + + +class FeatureExtractor: + """ + Converts a list of findings dicts into a FeatureVector. + Handles Arabic/English names via fuzzy substring matching. + """ + + def extract(self, findings: list[dict]) -> FeatureVector: + fv = FeatureVector() + for finding in findings: + name_lower = (finding.get("name") or finding.get("test_name") or "").lower().strip() + val_str = finding.get("value", "") + status = finding.get("status", "normal") + + try: + val = float(val_str) + except (ValueError, TypeError): + continue + + for feature, aliases in _FEATURE_MAP.items(): + if any(alias in name_lower for alias in aliases): + if feature not in fv.values: + fv.values[feature] = val + fv.statuses[feature] = status + break + + return fv diff --git a/backend/services/risk/risk_engine.py b/backend/services/risk/risk_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..3773c6ccd7cd2961ac3209093ed51760c5eaf730 --- /dev/null +++ b/backend/services/risk/risk_engine.py @@ -0,0 +1,411 @@ +""" +Risk Engine — evidence-based health risk scoring for Arabic medical reports. + +Primary risks: + - diabetes_risk (Type 2 diabetes / prediabetes) + - cardiovascular_risk (heart disease / atherosclerosis) + - anemia_risk (iron-deficiency or chronic) + - kidney_risk (CKD staging) + - liver_risk (hepatic dysfunction) + - thyroid_risk (hypo/hyperthyroidism) + +Each risk scorer: + 1. Checks clinical thresholds (evidence-based cutoffs) + 2. Accumulates a weighted score (0–100) + 3. Provides an Arabic explanation of contributing factors + 4. Returns confidence based on how many markers are available + +When sklearn/XGBoost models are placed in services/risk/models/, +they override the rule-based scores for those conditions. +""" +from __future__ import annotations +import logging +from dataclasses import dataclass, field +from pathlib import Path + +from .feature_extractor import FeatureExtractor, FeatureVector + +log = logging.getLogger("tebyan.risk") + +_EXTRACTOR = FeatureExtractor() + +# ── Risk result types ────────────────────────────────────────────────────── + +@dataclass +class RiskScore: + condition: str # e.g. "diabetes_risk" + score: float # 0–100 + level: str # "low" | "moderate" | "high" | "critical" + confidence: str # "high" | "medium" | "low" + label_ar: str # Human-readable Arabic label + factors: list[str] # contributing factors (Arabic) + recommendation: str # 1-sentence Arabic recommendation + source: str = "rules" # "rules" | "ml_xgboost" | "ml_rf" + + +@dataclass +class RiskReport: + risks: list[RiskScore] = field(default_factory=list) + top_risk: RiskScore | None = None + overall_ar: str = "" + features_used: dict = field(default_factory=dict) + + +# ── Helpers ──────────────────────────────────────────────────────────────── + +def _level(score: float) -> str: + if score >= 70: return "critical" + if score >= 45: return "high" + if score >= 20: return "moderate" + return "low" + +def _level_ar(level: str) -> str: + return {"low": "منخفض", "moderate": "متوسط", "high": "مرتفع", "critical": "حرج"}.get(level, level) + +def _conf(n_markers: int, n_available: int) -> str: + if n_available == 0: return "low" + ratio = n_available / max(n_markers, 1) + if ratio >= 0.7: return "high" + if ratio >= 0.4: return "medium" + return "low" + + +# ── Individual risk scorers ──────────────────────────────────────────────── + +def _score_diabetes(fv: FeatureVector) -> RiskScore: + score, factors = 0.0, [] + markers = ["glucose", "hba1c", "insulin", "homa_ir"] + available = sum(1 for m in markers if fv.has(m)) + + if fv.has("hba1c"): + a1c = fv.get("hba1c") + if a1c >= 6.5: + score += 50; factors.append(f"HbA1c مرتفع جداً ({a1c:.1f}%) — يدل على السكري") + elif a1c >= 5.7: + score += 30; factors.append(f"HbA1c حدّي ({a1c:.1f}%) — ما قبل السكري") + + if fv.has("glucose"): + g = fv.get("glucose") + if g >= 126: + score += 40; factors.append(f"سكر الصيام مرتفع ({g:.0f} mg/dL) — مستوى السكري") + elif g >= 100: + score += 20; factors.append(f"سكر الصيام حدّي ({g:.0f} mg/dL)") + + if fv.has("homa_ir") and fv.get("homa_ir") >= 2.5: + score += 15; factors.append("مقاومة الأنسولين مرتفعة") + + if fv.has("triglycerides") and fv.get("triglycerides") >= 200: + score += 10; factors.append("ثلاثيات الجليسريد مرتفعة — مرتبطة بمقاومة الأنسولين") + + score = min(score, 100) + lvl = _level(score) + rec = "يُنصح بإجراء منحنى سكر واستشارة طبيب الغدد." if score >= 45 else "تابع نظامك الغذائي ومستوى السكر." + return RiskScore( + condition="diabetes_risk", score=round(score, 1), level=lvl, + confidence=_conf(4, available), label_ar=f"خطر السكري — {_level_ar(lvl)}", + factors=factors, recommendation=rec, + ) + + +def _score_cardiovascular(fv: FeatureVector) -> RiskScore: + score, factors = 0.0, [] + markers = ["ldl", "hdl", "cholesterol", "triglycerides"] + available = sum(1 for m in markers if fv.has(m)) + + if fv.has("ldl"): + ldl = fv.get("ldl") + if ldl >= 190: + score += 45; factors.append(f"LDL مرتفع جداً ({ldl:.0f} mg/dL)") + elif ldl >= 160: + score += 30; factors.append(f"LDL مرتفع ({ldl:.0f} mg/dL)") + elif ldl >= 130: + score += 15; factors.append(f"LDL حدّي ({ldl:.0f} mg/dL)") + + if fv.has("hdl"): + hdl = fv.get("hdl") + if hdl < 35: + score += 25; factors.append(f"HDL منخفض جداً ({hdl:.0f} mg/dL) — تراجع الحماية القلبية") + elif hdl < 40: + score += 12; factors.append(f"HDL منخفض ({hdl:.0f} mg/dL)") + + if fv.has("triglycerides"): + tg = fv.get("triglycerides") + if tg >= 500: + score += 30; factors.append(f"⚠️ ثلاثيات الجليسريد حرجة ({tg:.0f}) — خطر التهاب البنكرياس") + elif tg >= 200: + score += 15; factors.append(f"ثلاثيات الجليسريد مرتفعة ({tg:.0f} mg/dL)") + + if fv.has("cholesterol"): + tc = fv.get("cholesterol") + if tc >= 240: + score += 15; factors.append(f"كوليسترول كلي مرتفع ({tc:.0f} mg/dL)") + + # Synergy penalty: low HDL + high LDL = double risk + if fv.is_low("hdl") and fv.is_high("ldl"): + score = min(score + 15, 100); factors.append("مثلث الخطر: HDL منخفض + LDL مرتفع") + + score = min(score, 100) + lvl = _level(score) + rec = "راجع طبيب القلب وتحدث عن دواء الستاتين." if score >= 45 else "تابع نظامك الغذائي وممارسة الرياضة." + return RiskScore( + condition="cardiovascular_risk", score=round(score, 1), level=lvl, + confidence=_conf(4, available), label_ar=f"خطر القلب والأوعية — {_level_ar(lvl)}", + factors=factors, recommendation=rec, + ) + + +def _score_anemia(fv: FeatureVector) -> RiskScore: + score, factors = 0.0, [] + markers = ["hemoglobin", "ferritin", "iron", "mcv"] + available = sum(1 for m in markers if fv.has(m)) + + if fv.has("hemoglobin"): + hb = fv.get("hemoglobin") + if hb < 7: + score += 80; factors.append(f"هيموجلوبين حرج ({hb:.1f} g/dL) — فقر دم شديد") + elif hb < 10: + score += 55; factors.append(f"هيموجلوبين منخفض ({hb:.1f} g/dL) — فقر دم متوسط") + elif hb < 12: + score += 30; factors.append(f"هيموجلوبين حدّي ({hb:.1f} g/dL) — فقر دم خفيف") + + if fv.has("ferritin"): + fer = fv.get("ferritin") + if fer < 12: + score += 20; factors.append(f"فيريتين منخفض جداً ({fer:.0f} ng/mL) — نضوب مخازن الحديد") + elif fer < 30: + score += 10; factors.append(f"فيريتين منخفض ({fer:.0f} ng/mL)") + + if fv.has("mcv"): + mcv = fv.get("mcv") + if mcv < 70: + score += 15; factors.append(f"MCV منخفض ({mcv:.0f} fL) — يدل على نقص الحديد") + elif mcv > 100: + score += 10; factors.append(f"MCV مرتفع ({mcv:.0f} fL) — محتمل نقص B12/فولات") + + score = min(score, 100) + lvl = _level(score) + rec = "يُنصح بإجراء فحص فيريتين وحديد وفيتامين B12 وزيارة طبيب الدم." if score >= 45 else "تابع مستوى الحديد في الفحوصات القادمة." + return RiskScore( + condition="anemia_risk", score=round(score, 1), level=lvl, + confidence=_conf(4, available), label_ar=f"خطر فقر الدم — {_level_ar(lvl)}", + factors=factors, recommendation=rec, + ) + + +def _score_kidney(fv: FeatureVector) -> RiskScore: + score, factors = 0.0, [] + markers = ["creatinine", "egfr", "bun", "uric_acid"] + available = sum(1 for m in markers if fv.has(m)) + + if fv.has("egfr"): + gfr = fv.get("egfr") + if gfr < 15: + score += 90; factors.append(f"eGFR حرج ({gfr:.0f}) — الفشل الكلوي G5") + elif gfr < 30: + score += 70; factors.append(f"eGFR منخفض جداً ({gfr:.0f}) — CKD G4") + elif gfr < 45: + score += 45; factors.append(f"eGFR منخفض ({gfr:.0f}) — CKD G3b") + elif gfr < 60: + score += 25; factors.append(f"eGFR حدّي ({gfr:.0f}) — CKD G3a") + + if fv.has("creatinine"): + cr = fv.get("creatinine") + if cr > 3: + score = max(score, 60); factors.append(f"كرياتينين مرتفع جداً ({cr:.1f} mg/dL)") + elif cr > 1.5: + score = max(score, 30); factors.append(f"كرياتينين مرتفع ({cr:.1f} mg/dL)") + + if fv.has("bun") and fv.has("creatinine"): + ratio = fv.get("bun") / max(fv.get("creatinine"), 0.1) + if ratio > 20: + score += 10; factors.append(f"نسبة BUN/Creatinine مرتفعة ({ratio:.0f}) — محتمل جفاف أو نزيف خفي") + + score = min(score, 100) + lvl = _level(score) + rec = "يُنصح بمراجعة طبيب الكلى وتجنب الأدوية الكلوية السمية." if score >= 30 else "تابع وظائف الكلى دورياً." + return RiskScore( + condition="kidney_risk", score=round(score, 1), level=lvl, + confidence=_conf(4, available), label_ar=f"خطر أمراض الكلى — {_level_ar(lvl)}", + factors=factors, recommendation=rec, + ) + + +def _score_liver(fv: FeatureVector) -> RiskScore: + score, factors = 0.0, [] + markers = ["alt", "ast", "alp", "ggt", "bilirubin", "albumin"] + available = sum(1 for m in markers if fv.has(m)) + + if fv.has("alt"): + alt = fv.get("alt") + if alt > 500: + score += 60; factors.append(f"ALT مرتفع جداً ({alt:.0f} U/L) — التهاب كبد حاد") + elif alt > 100: + score += 35; factors.append(f"ALT مرتفع ({alt:.0f} U/L)") + elif alt > 56: + score += 15; factors.append(f"ALT حدّي ({alt:.0f} U/L)") + + if fv.has("ast"): + ast = fv.get("ast") + if ast > 500: + score += 40; factors.append(f"AST مرتفع جداً ({ast:.0f} U/L)") + elif ast > 100: + score += 20; factors.append(f"AST مرتفع ({ast:.0f} U/L)") + + if fv.has("bilirubin") and fv.get("bilirubin") > 3: + score += 20; factors.append(f"بيليروبين مرتفع ({fv.get('bilirubin'):.1f} mg/dL) — اليرقان") + + if fv.has("albumin") and fv.get("albumin") < 3: + score += 20; factors.append(f"ألبومين منخفض ({fv.get('albumin'):.1f} g/dL) — اختلال وظيفي") + + if fv.has("inr") and fv.get("inr") > 1.5: + score += 15; factors.append(f"INR مرتفع ({fv.get('inr'):.1f}) — اضطراب التخثر الكبدي") + + score = min(score, 100) + lvl = _level(score) + rec = "يُنصح بمراجعة طبيب الجهاز الهضمي وإجراء سونار الكبد." if score >= 30 else "تابع إنزيمات الكبد دورياً." + return RiskScore( + condition="liver_risk", score=round(score, 1), level=lvl, + confidence=_conf(6, available), label_ar=f"خطر أمراض الكبد — {_level_ar(lvl)}", + factors=factors, recommendation=rec, + ) + + +def _score_thyroid(fv: FeatureVector) -> RiskScore: + score, factors = 0.0, [] + markers = ["tsh", "ft4", "ft3"] + available = sum(1 for m in markers if fv.has(m)) + + if fv.has("tsh"): + tsh = fv.get("tsh") + if tsh > 10: + score += 60; factors.append(f"TSH مرتفع جداً ({tsh:.2f} mIU/L) — قصور درقي حاد") + elif tsh > 4: + score += 30; factors.append(f"TSH مرتفع ({tsh:.2f} mIU/L) — قصور درقي خفيف") + elif tsh < 0.1: + score += 50; factors.append(f"TSH منخفض جداً ({tsh:.3f} mIU/L) — فرط درقي") + elif tsh < 0.4: + score += 25; factors.append(f"TSH منخفض ({tsh:.3f} mIU/L)") + + if fv.has("ft4") and fv.is_low("ft4"): + score += 20; factors.append(f"FT4 منخفض ({fv.get('ft4'):.2f}) — تأكيد قصور درقي") + + score = min(score, 100) + lvl = _level(score) + rec = "يُنصح بمراجعة طبيب الغدد الصماء وفحص Anti-TPO." if score >= 30 else "تابع مستوى TSH دورياً." + return RiskScore( + condition="thyroid_risk", score=round(score, 1), level=lvl, + confidence=_conf(3, available), label_ar=f"خطر أمراض الغدة الدرقية — {_level_ar(lvl)}", + factors=factors, recommendation=rec, + ) + + +# ── ML model loader (optional override) ─────────────────────────────────── + +def _try_load_ml_model(condition: str): + """ + Load a trained sklearn/XGBoost model from services/risk/models/. + Returns the model if found, else None (falls back to rules). + """ + model_path = Path(__file__).parent / "models" / f"{condition}.pkl" + if not model_path.exists(): + return None + try: + import pickle + with open(model_path, "rb") as f: + return pickle.load(f) + except Exception as e: + log.warning("[risk] failed to load ML model %s: %s", condition, e) + return None + + +# ── Main engine ──────────────────────────────────────────────────────────── + +class RiskEngine: + """ + Computes multi-condition health risk from a findings list. + Uses evidence-based rule scorers by default. + ML models (XGBoost/RandomForest) auto-loaded from services/risk/models/ + when available and override rule scores for that condition. + """ + + _SCORERS = [ + ("diabetes_risk", _score_diabetes), + ("cardiovascular_risk", _score_cardiovascular), + ("anemia_risk", _score_anemia), + ("kidney_risk", _score_kidney), + ("liver_risk", _score_liver), + ("thyroid_risk", _score_thyroid), + ] + + def __init__(self): + self._extractor = FeatureExtractor() + self._ml_models: dict = {} + for cond, _ in self._SCORERS: + m = _try_load_ml_model(cond) + if m: + self._ml_models[cond] = m + log.info("[risk] ML model loaded for %s", cond) + + def assess(self, findings: list[dict]) -> RiskReport: + fv = self._extractor.extract(findings) + risks: list[RiskScore] = [] + + for cond, scorer in self._SCORERS: + try: + rs = scorer(fv) + # Override with ML model if available and conditions are met + if cond in self._ml_models and fv.values: + rs = self._ml_predict(cond, fv, rs) + if rs.factors: # only include risks with evidence + risks.append(rs) + except Exception as e: + log.warning("[risk] scorer %s failed: %s", cond, e) + + risks.sort(key=lambda r: r.score, reverse=True) + top = risks[0] if risks else None + + overall = self._overall_summary(risks) + return RiskReport( + risks=risks, + top_risk=top, + overall_ar=overall, + features_used={"count": len(fv.values), "names": list(fv.values.keys())}, + ) + + def _ml_predict(self, cond: str, fv: FeatureVector, fallback: RiskScore) -> RiskScore: + """Override rule score with ML prediction if confidence is sufficient.""" + try: + model = self._ml_models[cond] + import numpy as np + feature_names = list(_EXTRACTOR._FEATURE_MAP.keys()) if hasattr(_EXTRACTOR, "_FEATURE_MAP") else list(fv.values.keys()) + x = np.array([[fv.get(f) for f in feature_names]]) + # Handle NaN via median imputation + col_means = np.nanmedian(x, axis=0) + inds = np.where(np.isnan(x)) + x[inds] = np.take(col_means, inds[1]) + prob = float(model.predict_proba(x)[0][1]) + ml_score = round(prob * 100, 1) + return RiskScore( + condition=fallback.condition, score=ml_score, level=_level(ml_score), + confidence="high", label_ar=fallback.label_ar.replace(_level_ar(fallback.level), _level_ar(_level(ml_score))), + factors=fallback.factors, recommendation=fallback.recommendation, + source="ml_xgboost", + ) + except Exception as e: + log.warning("[risk] ML predict failed for %s: %s — using rules", cond, e) + return fallback + + @staticmethod + def _overall_summary(risks: list[RiskScore]) -> str: + if not risks: + return "لم يُكتشف أي خطر صحي بارز من النتائج المتاحة." + critical = [r for r in risks if r.level == "critical"] + high = [r for r in risks if r.level == "high"] + if critical: + return f"تنبيه: مستوى خطر حرج في {', '.join(r.label_ar.split(' — ')[0] for r in critical)}. يُنصح بمراجعة الطبيب فوراً." + if high: + return f"مستوى خطر مرتفع في {', '.join(r.label_ar.split(' — ')[0] for r in high)}. يُنصح بمتابعة طبية قريبة." + moderate = [r for r in risks if r.level == "moderate"] + if moderate: + return f"بعض المؤشرات تستدعي المتابعة: {', '.join(r.label_ar.split(' — ')[0] for r in moderate)}." + return "المؤشرات الصحية ضمن الحدود المقبولة. استمر في التحاليل الدورية." diff --git a/backend/services/safety.py b/backend/services/safety.py new file mode 100644 index 0000000000000000000000000000000000000000..9effa85ef0a4dcd65e9c197ca0ec14d5135c747d --- /dev/null +++ b/backend/services/safety.py @@ -0,0 +1,193 @@ +""" +Medical Safety Layer — PDPL-compliant output filtering. +Prevents definitive diagnoses, medication dosing, and adds mandatory disclaimers. +""" +from __future__ import annotations +import re + +# ── Patterns that suggest definitive diagnosis ────────────────────────────── +_DIAGNOSIS_PATTERNS = [ + r"أنتِ?\s+مريض[ة]?\s+بـ?", + r"لديكِ?\s+(مرض|سرطان|داء|متلازمة)", + r"تشخيص[كِ]?\s+(هو|:)", + r"مصاب[ة]?\s+بـ?\s+\w+", + r"you\s+have\s+(cancer|disease|syndrome|disorder)", + r"diagnosed\s+with", +] + +# ── Patterns that suggest specific medication dosing ───────────────────────── +_MEDICATION_PATTERNS = [ + r"\d+\s*(mg|ملجم|ملغ|µg|mcg|IU)\s*(يومياً|daily|مرتين|ثلاث مرات)", + r"(خذ|تناول|اشرب)\s+\w+\s+\d+", + r"جرعة[كِ]?\s+(هي|:)\s+\d+", + r"دواء\s+\w+\s+\d+\s*(mg|ملجم)", +] + +# ── Emergency keywords → prepend urgent warning ────────────────────────────── +_EMERGENCY_KEYWORDS_AR = [ + "ألم صدر", "ضيق تنفس", "فقدان وعي", "نزيف حاد", "شلل", + "تعذر التنفس", "سكتة", "نوبة قلبية", "صعوبة التنفس", +] +_EMERGENCY_KEYWORDS_EN = [ + "chest pain", "difficulty breathing", "loss of consciousness", + "severe bleeding", "stroke", "heart attack", "paralysis", +] + +# ── PDPL-compliant disclaimer (وفق PDPL السعودي) ──────────────────────────── +DISCLAIMER_AR = ( + "\n\n⚕️ **تنبيه طبي:** هذه المعلومات لأغراض التوعية الصحية فقط " + "ولا تُغني عن استشارة طبيب مختص. لا تتخذ أي قرار علاجي بناءً عليها." +) + +EMERGENCY_WARNING = ( + "⚠️ **تحذير:** بعض هذه الأعراض قد تستدعي تقييماً طبياً عاجلاً. " + "إذا كنت تعاني من أعراض حادة، توجّه فوراً لأقرب طوارئ.\n\n" +) + + +def _contains_pattern(text: str, patterns: list[str]) -> bool: + for p in patterns: + if re.search(p, text, re.IGNORECASE): + return True + return False + + +def _has_emergency_keywords(text: str) -> bool: + t = text.lower() + return any(kw in t for kw in _EMERGENCY_KEYWORDS_AR + _EMERGENCY_KEYWORDS_EN) + + +def _soften_diagnosis(text: str) -> str: + """Replace definitive diagnosis phrases with cautious language.""" + replacements = [ + (r"أنتِ? مريض[ة]? بـ?(\w+)", r"النتائج قد تشير إلى \1"), + (r"لديكِ? (مرض|سرطان|داء)\s+(\w+)", r"النتائج قد تشير إلى \1 \2"), + (r"مصاب[ة]? بـ?\s*(\w+)", r"النتائج تستدعي فحص \1"), + (r"تشخيص[كِ]? هو\s+(\w+)", r"النتائج قد تشير إلى \1"), + ] + for pattern, replacement in replacements: + text = re.sub(pattern, replacement, text, flags=re.IGNORECASE) + return text + + +def _remove_dosing(text: str) -> str: + """Remove specific medication dosing instructions.""" + # Replace dosing with generic advice + text = re.sub( + r"(خذ|تناول)\s+\w+\s+\d+\s*(mg|ملجم|ملغ)", + "استشر طبيبك لتحديد الجرعة المناسبة", + text, + flags=re.IGNORECASE, + ) + text = re.sub( + r"\d+\s*(mg|ملجم|ملغ)\s*(يومياً|daily|مرتين)", + "(الجرعة يحددها الطبيب)", + text, + flags=re.IGNORECASE, + ) + return text + + +def filter_analysis_report(report: dict) -> dict: + """ + Apply safety filtering to the full analysis report dict. + Modifies: general, abnormal_details[].شرح + Adds: disclaimer to general + """ + # Filter general assessment + general = report.get("general", "") + if _contains_pattern(general, _DIAGNOSIS_PATTERNS): + general = _soften_diagnosis(general) + if _contains_pattern(general, _MEDICATION_PATTERNS): + general = _remove_dosing(general) + report["general"] = general + DISCLAIMER_AR + + # Filter each abnormal detail explanation + for item in report.get("abnormal_details", []): + sharh = item.get("الشرح", "") + if _contains_pattern(sharh, _DIAGNOSIS_PATTERNS): + sharh = _soften_diagnosis(sharh) + if _contains_pattern(sharh, _MEDICATION_PATTERNS): + sharh = _remove_dosing(sharh) + item["الشرح"] = sharh + + return report + + +def filter_chat_response(response_text: str, user_query: str = "") -> str: + """ + Apply safety filtering to chat response text. + - Softens definitive diagnoses + - Removes medication dosing + - Adds emergency warning if needed + - Adds disclaimer for medical advice + """ + text = response_text + + if _contains_pattern(text, _DIAGNOSIS_PATTERNS): + text = _soften_diagnosis(text) + + if _contains_pattern(text, _MEDICATION_PATTERNS): + text = _remove_dosing(text) + + # Add emergency warning if user asked about emergency symptoms + prefix = "" + if _has_emergency_keywords(user_query) and "⚠️" not in text[:50]: + prefix = EMERGENCY_WARNING + + # Add disclaimer only for substantive medical responses (>100 chars) + if len(text) > 100 and DISCLAIMER_AR not in text: + text = text + DISCLAIMER_AR + + return prefix + text + + +_INJECTION_PATTERNS = [ + # Prompt override attempts + r"ignore\s+(previous|all|above|prior)\s+(instructions?|prompts?|context)", + r"disregard\s+(all|your|previous)\s+(instructions?|rules?|guidelines?)", + r"you\s+are\s+now\s+(a|an|the)\s+\w+", + r"(act|pretend|behave|roleplay)\s+as\s+(if\s+you\s+(are|were)\s+)?(\w+\s*){1,4}", + r"forget\s+your\s+(training|guidelines?|instructions?|rules?)", + r"(system\s+)?prompt\s*(:|=|is|says?)\s*[\"']", + r"jailbreak", + r"do\s+anything\s+now", + r"dan\s+mode", + # Arabic injection patterns + r"تجاهل\s+(التعليمات|السياق|الأوامر)", + r"أنت\s+(الآن|الان)\s+\w+", + r"تصرف\s+كـ?\w+", +] +_INJECTION_RE = re.compile("|".join(_INJECTION_PATTERNS), re.IGNORECASE) + +# Characters/sequences to strip before inserting into prompts +_PROMPT_DELIMITERS = re.compile(r"(<\|.*?\|>|###|---+|===+|\[INST\]|\[/INST\]|<>|<>)") + + +def sanitize_query(query: str, max_len: int = 1000) -> str: + """ + Strip prompt-injection attempts from user input before RAG/LLM insertion. + Returns cleaned query; never raises. + """ + q = query[:max_len] + q = _PROMPT_DELIMITERS.sub(" ", q) + if _INJECTION_RE.search(q): + # Blank out the injection fragment but keep any preceding medical text + q = _INJECTION_RE.sub("[محتوى محذوف]", q) + return q.strip() + + +def check_emergency(user_query: str) -> str | None: + """ + Returns an immediate emergency response string if query contains + emergency keywords, else None. + Only fires for very clear emergency phrases. + """ + critical = ["ألم صدر شديد", "صعوبة تنفس حادة", "فقدان وعي", "نزيف لا يتوقف"] + q = user_query.lower() + if any(kw in q for kw in critical): + return ( + "⚠️ هذه الأعراض تستدعي التوجه فوراً لأقرب طوارئ أو الاتصال بالإسعاف.\n" + "لا تنتظر ولا تقود بنفسك. أطلب المساعدة الآن." + ) + return None diff --git a/backend/services/search/__init__.py b/backend/services/search/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e8cb88d5377c8d35d62918a4694dd74b39968e2e --- /dev/null +++ b/backend/services/search/__init__.py @@ -0,0 +1,11 @@ +from .embedding_service import embed_query, embed_batch, get_embeddings +from .semantic_search import SemanticSearchService, SearchResult +from .query_parser import parse_query, ParsedQuery +from .similarity import cosine_similarity, cosine_matrix + +__all__ = [ + "embed_query", "embed_batch", "get_embeddings", + "SemanticSearchService", "SearchResult", + "parse_query", "ParsedQuery", + "cosine_similarity", "cosine_matrix", +] diff --git a/backend/services/search/embedding_service.py b/backend/services/search/embedding_service.py new file mode 100644 index 0000000000000000000000000000000000000000..6d17fe39c7bca662d14d26458e0c15090b129a51 --- /dev/null +++ b/backend/services/search/embedding_service.py @@ -0,0 +1,46 @@ +""" +Centralized embedding service — singleton HuggingFaceEmbeddings with LRU query cache. +All parts of the app embed through here so the model is loaded only once. +""" +from __future__ import annotations +import logging +from functools import lru_cache + +log = logging.getLogger("tebyan.embeddings") + +EMBED_MODEL = "intfloat/multilingual-e5-large" + +# LRU query cache — avoids re-embedding repeated queries +_cache: dict[str, list[float]] = {} +_MAX_CACHE = 500 + + +@lru_cache(maxsize=1) +def get_embeddings(): + """Return the singleton HuggingFaceEmbeddings instance.""" + from langchain_huggingface import HuggingFaceEmbeddings + emb = HuggingFaceEmbeddings(model_name=EMBED_MODEL) + log.info("Embedding model loaded: %s", EMBED_MODEL) + return emb + + +def embed_query(text: str) -> list[float]: + """Embed a single query string with LRU caching.""" + if text not in _cache: + if len(_cache) >= _MAX_CACHE: + _cache.pop(next(iter(_cache))) + _cache[text] = get_embeddings().embed_query(text) + return _cache[text] + + +def embed_batch(texts: list[str]) -> list[list[float]]: + """Embed a batch of documents (no caching — for ingest only).""" + return get_embeddings().embed_documents(texts) + + +def cache_size() -> int: + return len(_cache) + + +def clear_cache() -> None: + _cache.clear() diff --git a/backend/services/search/query_parser.py b/backend/services/search/query_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..7b7f06e1fd71f7d1c1f1340df9000708f8e031b6 --- /dev/null +++ b/backend/services/search/query_parser.py @@ -0,0 +1,283 @@ +""" +Query parser — detects language, medical panel, lab tests, and symptoms. +Includes Arabic medical synonym expansion and text normalization. +""" +from __future__ import annotations +import re +from dataclasses import dataclass, field + +# ══════════════════════════════════════════════════════════════════ +# Arabic text normalization +# ══════════════════════════════════════════════════════════════════ + +def normalize_arabic(text: str) -> str: + """ + Normalize Arabic text for consistent matching: + - Unify alef variants (أ إ آ → ا) + - Remove tashkeel (diacritics) + - Remove tatweel (ـ) + - Unify teh marbuta and heh + """ + text = re.sub(r"[أإآ]", "ا", text) + text = re.sub(r"ى", "ي", text) + text = re.sub(r"ة", "ه", text) + text = re.sub(r"[ؐ-ًؚ-ٟ]", "", text) # tashkeel + text = re.sub(r"ـ", "", text) # tatweel + return text.strip() + + +# ══════════════════════════════════════════════════════════════════ +# Arabic Medical Synonym Dictionary +# Maps any Arabic/English term → canonical synonyms for expansion +# ══════════════════════════════════════════════════════════════════ + +_MEDICAL_SYNONYMS: dict[str, list[str]] = { + # ── فقر الدم / Anemia ───────────────────────────────────────── + "فقر الدم": ["anemia", "hemoglobin low", "هيموجلوبين منخفض", "انيميا"], + "انيميا": ["anemia", "فقر الدم", "hemoglobin low", "هيموجلوبين"], + "هيموجلوبين منخفض": ["anemia", "فقر الدم", "low hemoglobin", "iron deficiency"], + "نقص الحديد": ["iron deficiency", "ferritin low", "فيريتين منخفض", "anemia"], + "فقر دم": ["anemia", "hemoglobin", "هيموجلوبين", "iron deficiency"], + + # ── السكري / Diabetes ───────────────────────────────────────── + "مشاكل السكر": ["diabetes", "glucose high", "HbA1c", "سكر مرتفع", "hyperglycemia"], + "سكر مرتفع": ["high glucose", "hyperglycemia", "diabetes", "HbA1c elevated"], + "سكر منخفض": ["hypoglycemia", "low glucose", "نقص السكر"], + "نقص السكر": ["hypoglycemia", "low glucose", "سكر منخفض"], + "مقاومة الانسولين": ["insulin resistance", "HOMA-IR", "prediabetes", "ما قبل السكري"], + "ما قبل السكري": ["prediabetes", "insulin resistance", "glucose borderline"], + + # ── الغدة الدرقية / Thyroid ─────────────────────────────────── + "خمول الغدة": ["hypothyroidism", "TSH high", "TSH مرتفع", "قصور درقية"], + "قصور درقية": ["hypothyroidism", "TSH high", "خمول الغدة الدرقية"], + "قصور الغدة الدرقيه": ["hypothyroidism", "TSH high", "خمول الغدة"], + "نشاط الغدة": ["hyperthyroidism", "TSH low", "TSH منخفض", "فرط درقية"], + "فرط درقية": ["hyperthyroidism", "TSH low", "نشاط الغدة الدرقية"], + "هاشيموتو": ["Hashimoto", "anti-TPO", "hypothyroidism", "autoimmune thyroid"], + + # ── الكبد / Liver ───────────────────────────────────────────── + "مشاكل الكبد": ["liver disease", "ALT high", "AST high", "hepatitis", "كبد دهني"], + "كبد دهني": ["fatty liver", "NAFLD", "ALT elevated", "hepatic steatosis"], + "التهاب الكبد": ["hepatitis", "ALT high", "AST high", "liver inflammation"], + "انزيمات الكبد": ["liver enzymes", "ALT", "AST", "ALP", "GGT"], + "ارتفاع الانزيمات": ["elevated liver enzymes", "ALT high", "AST high", "hepatitis"], + + # ── الكلى / Kidney ──────────────────────────────────────────── + "مشاكل الكلى": ["kidney disease", "creatinine high", "eGFR low", "renal failure"], + "ضعف الكلى": ["kidney failure", "creatinine elevated", "eGFR low", "CKD"], + "فشل كلوي": ["renal failure", "kidney failure", "creatinine high", "eGFR critical"], + "حصى الكلى": ["kidney stones", "uric acid high", "calcium oxalate"], + + # ── القلب والدهون / Cardiology & Lipids ─────────────────────── + "كوليسترول مرتفع": ["high cholesterol", "hyperlipidemia", "LDL high", "cardiovascular risk"], + "دهون مرتفعه": ["high triglycerides", "hyperlipidemia", "cholesterol high"], + "خطر قلبي": ["cardiovascular risk", "LDL high", "cholesterol", "heart disease risk"], + "تصلب الشرايين": ["atherosclerosis", "LDL high", "cardiovascular", "cholesterol"], + + # ── الغدد والهرمونات / Hormones ────────────────────────────── + "نقص فيتامين د": ["vitamin D deficiency", "low vitamin D", "bone health"], + "نقص ب12": ["vitamin B12 deficiency", "B12 low", "megaloblastic anemia"], + "نقص فيتامين ب": ["vitamin B12 deficiency", "B12 low", "folate deficiency"], + + # ── أعراض عامة / General symptoms ──────────────────────────── + "تعب وارهاق": ["fatigue", "anemia", "vitamin deficiency", "thyroid", "تعب مزمن"], + "تعب مزمن": ["chronic fatigue", "anemia", "thyroid", "vitamin D deficiency"], + "نتائج مثيرة للقلق": ["abnormal results", "high values", "low values", "critical values"], + "نتائج غير طبيعيه": ["abnormal results", "out of range", "high low values"], + "نتائج سيئه": ["abnormal results", "critical values", "out of range"], + "ضغط الدم": ["blood pressure", "hypertension", "sodium", "kidney"], + "التهاب": ["inflammation", "CRP high", "ESR high", "WBC elevated"], + "مناعه منخفضه": ["low immunity", "WBC low", "lymphocytes low", "immune deficiency"], + + # ── حالة حمل / Pregnancy ───────────────────────────────────── + "تحاليل حمل": ["pregnancy labs", "hemoglobin pregnancy", "thyroid pregnancy", "glucose pregnancy"], + "فقر دم حمل": ["pregnancy anemia", "hemoglobin low pregnancy", "iron deficiency pregnancy"], +} + +# Normalized lookup — built once at import +_SYNONYM_LOOKUP: dict[str, list[str]] = { + normalize_arabic(k): v for k, v in _MEDICAL_SYNONYMS.items() +} + + +def expand_medical_synonyms(query: str) -> list[str]: + """ + Find medical synonym expansions for any phrase in the query. + Returns list of expansion strings (empty if no match). + """ + q_norm = normalize_arabic(query.lower()) + expansions: list[str] = [] + for term, synonyms in _SYNONYM_LOOKUP.items(): + if term in q_norm: + expansions.append(" ".join(synonyms[:4])) + return expansions[:2] # max 2 synonym expansions + + +# ══════════════════════════════════════════════════════════════════ +# Keyword sets +# ══════════════════════════════════════════════════════════════════ + +_LAB_AR = { + "هيموجلوبين", "خضاب الدم", "كريات حمراء", "كريات بيضاء", + "صفائح دموية", "صفائح", "هيماتوكريت", "سكر", "سكر صيام", + "سكر تراكمي", "كوليسترول", "دهون ثلاثية", "كرياتينين", + "يوريا", "بيليروبين", "البومين", "بروتين", "فيريتين", + "حديد", "فيتامين د", "فيتامين ب12", "كالسيوم", "صوديوم", + "بوتاسيوم", "حمض البول", "فولات", +} + +_LAB_EN = { + "hemoglobin", "wbc", "rbc", "platelet", "platelets", "hematocrit", + "glucose", "hba1c", "a1c", "cholesterol", "ldl", "hdl", "triglycerides", + "creatinine", "bun", "urea", "bilirubin", "albumin", "alt", "ast", "alp", + "ggt", "sgpt", "sgot", "tsh", "ft3", "ft4", "free t4", "free t3", + "ferritin", "iron", "vitamin d", "vitamin b12", "b12", "protein", + "calcium", "sodium", "potassium", "crp", "esr", "folate", "uric acid", + "cbc", "lft", "kft", "inr", "pt", "egfr", "gfr", "homa-ir", "insulin", +} + +_SYMPTOM_AR = { + "الم", "تعب", "ارهاق", "دوخة", "صداع", "حمى", "سعال", + "ضيق تنفس", "خفقان", "شحوب", "اصفرار", + "تورم", "فقدان شهية", "غثيان", "تقيؤ", "اسهال", "امساك", + "عطش", "برود", "نزيف", "حكة", +} + +_SYMPTOM_EN = { + "pain", "fatigue", "tired", "dizzy", "headache", "fever", "cough", + "breathless", "palpitation", "pale", "jaundice", "swelling", + "nausea", "vomiting", "thirst", +} + +# ── Panel detection keywords ─────────────────────────────────────────────── + +_PANEL_KEYWORDS: dict[str, list[str]] = { + "cbc": ["hemoglobin", "هيموجلوبين", "wbc", "rbc", "platelet", "صفائح", "cbc", + "دم شامل", "hct", "hematocrit", "فقر الدم", "انيميا", "anemia"], + "thyroid": ["tsh", "t3", "t4", "ft3", "ft4", "درقية", "thyroid", "هرمون درقي", + "هاشيموتو", "جريفز", "خمول الغدة", "قصور درقية", "hypothyroidism", "hyperthyroidism"], + "liver": ["alt", "ast", "alp", "ggt", "bilirubin", "albumin", "كبد", "liver", + "بيليروبين", "sgpt", "sgot", "lft", "كبد دهني", "التهاب الكبد", "انزيمات"], + "kidney": ["creatinine", "bun", "urea", "كلى", "كرياتينين", "kidney", "gfr", + "egfr", "kft", "فشل كلوي", "حصى الكلى", "ضعف الكلى"], + "diabetes": ["glucose", "hba1c", "سكر", "diabetes", "a1c", "سكر تراكمي", + "مقاومة الانسولين", "insulin", "homa", "ما قبل السكري"], + "lipid": ["cholesterol", "ldl", "hdl", "triglycerides", "كوليسترول", + "دهون", "دهنيات", "كوليسترول مرتفع", "تصلب الشرايين"], +} + +_PANEL_EXPANSIONS: dict[str, str] = { + "cbc": "CBC complete blood count hemoglobin WBC RBC platelets anemia iron deficiency", + "thyroid": "thyroid function TSH T4 T3 hypothyroidism hyperthyroidism Hashimoto autoimmune", + "liver": "liver function ALT AST ALP bilirubin albumin hepatitis cirrhosis fatty liver", + "kidney": "kidney renal function creatinine BUN urea GFR eGFR nephrology CKD", + "diabetes": "diabetes blood glucose HbA1c insulin resistance glycemic prediabetes HOMA-IR", + "lipid": "lipid cholesterol LDL HDL triglycerides cardiovascular risk atherosclerosis", +} + + +# ══════════════════════════════════════════════════════════════════ +# Data class +# ══════════════════════════════════════════════════════════════════ + +@dataclass +class ParsedQuery: + original: str + normalized: str + language: str + detected_tests: list[str] = field(default_factory=list) + detected_symptoms: list[str] = field(default_factory=list) + panel_type: str | None = None + topic_type: str | None = None + search_expansions: list[str] = field(default_factory=list) + synonym_matches: list[str] = field(default_factory=list) + + +# ══════════════════════════════════════════════════════════════════ +# Helpers +# ══════════════════════════════════════════════════════════════════ + +def _detect_language(text: str) -> str: + ar = len(re.findall(r"[؀-ۿ]", text)) + en = len(re.findall(r"[a-zA-Z]", text)) + if ar > en * 1.5: + return "ar" + if en > ar * 1.5: + return "en" + return "mixed" + + +def _detect_panel(text_lower: str) -> str | None: + for panel, kws in _PANEL_KEYWORDS.items(): + if any(kw.lower() in text_lower for kw in kws): + return panel + return None + + +def detect_panel(text: str) -> str | None: + """Public wrapper — accepts raw text, handles normalization internally.""" + norm = normalize_arabic(text.lower()) + result = _detect_panel(norm) + if not result: + result = _detect_panel(text.lower()) + return result + + +# ══════════════════════════════════════════════════════════════════ +# Main function +# ══════════════════════════════════════════════════════════════════ + +def parse_query(query: str) -> ParsedQuery: + norm_query = normalize_arabic(query) + lower = norm_query.lower() + lang = _detect_language(query) + + # Lab test detection (normalized) + detected_tests: list[str] = [] + for kw in _LAB_AR | _LAB_EN: + if normalize_arabic(kw.lower()) in lower: + detected_tests.append(kw) + + # Symptom detection (normalized) + detected_symptoms: list[str] = [] + for kw in _SYMPTOM_AR | _SYMPTOM_EN: + if normalize_arabic(kw.lower()) in lower: + detected_symptoms.append(kw) + + panel = detect_panel(query) + + topic_type = ( + "lab_test" if detected_tests + else "symptom" if detected_symptoms + else None + ) + + # ── Build search expansions ──────────────────────────────────── + expansions: list[str] = [query] + + # 1. Synonym expansion (Arabic medical synonyms) + synonym_expansions = expand_medical_synonyms(query) + synonym_matches = synonym_expansions[:] + expansions.extend(synonym_expansions) + + # 2. Panel expansion (bilingual medical terms) + if panel and panel in _PANEL_EXPANSIONS: + expansions.append(_PANEL_EXPANSIONS[panel]) + + # 3. Detected EN test names + if detected_tests: + en_tests = [t for t in detected_tests if re.search(r"[a-zA-Z]", t)] + if en_tests: + expansions.append(" ".join(en_tests[:5])) + + return ParsedQuery( + original=query, + normalized=norm_query, + language=lang, + detected_tests=list(set(detected_tests)), + detected_symptoms=list(set(detected_symptoms)), + panel_type=panel, + topic_type=topic_type, + search_expansions=list(dict.fromkeys(expansions))[:4], + synonym_matches=synonym_matches, + ) diff --git a/backend/services/search/semantic_search.py b/backend/services/search/semantic_search.py new file mode 100644 index 0000000000000000000000000000000000000000..69c6415d6d2d027a53d776b67bad8d87424fd03c --- /dev/null +++ b/backend/services/search/semantic_search.py @@ -0,0 +1,136 @@ +""" +SemanticSearchService — replaces PGVectorStore in main.py. +Wraps Supabase REST pgvector with typed SearchResult output. +""" +from __future__ import annotations +import logging +from dataclasses import dataclass, field + +import requests as http_requests + +log = logging.getLogger("tebyan.semantic_search") + +from .embedding_service import embed_query +from .query_parser import parse_query + + +@dataclass +class SearchResult: + content: str + score: float + source: str + metadata: dict = field(default_factory=dict) + + def __repr__(self) -> str: + return ( + f"SearchResult(score={self.score:.3f}, " + f"source={self.source!r}, " + f"content={self.content[:60]!r})" + ) + + +class SemanticSearchService: + """ + Unified interface over Supabase pgvector REST API. + Handles embedding, vector search, metadata filtering, and score boosting. + """ + + def __init__( + self, + supabase_url: str, + supabase_key: str, + match_threshold: float = 0.3, + ) -> None: + self._url = supabase_url.rstrip("/") + self._key = supabase_key + self._threshold = match_threshold + self._headers = { + "apikey": supabase_key, + "Authorization": f"Bearer {supabase_key}", + "Content-Type": "application/json", + } + + # ── Low-level vector search ──────────────────────────────────────────── + + def _vector_search( + self, + query: str, + k: int = 10, + filter_meta: dict | None = None, + ) -> list[SearchResult]: + vec = embed_query(query) + payload = { + "query_embedding": vec, + "match_threshold": self._threshold, + "match_count": k, + "filter": filter_meta or {}, + } + try: + r = http_requests.post( + f"{self._url}/rest/v1/rpc/match_documents", + headers=self._headers, + json=payload, + timeout=15, + ) + r.raise_for_status() + return [ + SearchResult( + content=row["content"], + score=float(row["similarity"]), + source=(row.get("metadata") or {}).get("source", ""), + metadata=row.get("metadata") or {}, + ) + for row in r.json() + ] + except Exception as e: + log.error("vector search failed: %s", e) + return [] + + # ── Public search ────────────────────────────────────────────────────── + + def search( + self, + query: str, + k: int = 10, + topic_type: str | None = None, + ) -> list[SearchResult]: + """ + Search with optional topic_type metadata filter. + Boosts panel-specific results if panel is detected from query. + """ + parsed = parse_query(query) + filter_meta = {"topic_type": topic_type} if topic_type else {} + + # Primary search + seen: dict[str, SearchResult] = {} + for r in self._vector_search(query, k=k, filter_meta=filter_meta): + key = r.content[:80] + if key not in seen or seen[key].score < r.score: + seen[key] = r + + # Boosted search for detected panel + if parsed.panel_type and not topic_type: + for r in self._vector_search( + query, k=min(k, 5), filter_meta={"panel_type": parsed.panel_type} + ): + key = r.content[:80] + r.score = min(r.score + 0.1, 1.0) + if key not in seen or seen[key].score < r.score: + seen[key] = r + + filtered = [r for r in seen.values() if r.score >= self._threshold] + return sorted(filtered, key=lambda r: r.score, reverse=True) + + # ── Utility ──────────────────────────────────────────────────────────── + + def count(self) -> int: + """Return total document count in the pgvector store.""" + try: + r = http_requests.get( + f"{self._url}/rest/v1/documents", + headers={**self._headers, "Prefer": "count=exact", "Range": "0-0"}, + timeout=10, + ) + return int(r.headers.get("Content-Range", "0/0").split("/")[-1]) + except Exception: + return 0 diff --git a/backend/services/search/similarity.py b/backend/services/search/similarity.py new file mode 100644 index 0000000000000000000000000000000000000000..c6778a588bd79c5cada86190614846d5f9d7bd4e --- /dev/null +++ b/backend/services/search/similarity.py @@ -0,0 +1,46 @@ +""" +Cosine similarity utilities. +Used for in-memory re-ranking and score comparison. +""" +from __future__ import annotations +import numpy as np + + +def cosine_similarity(a: list[float], b: list[float]) -> float: + """Cosine similarity between two vectors. Returns value in [-1, 1].""" + va = np.array(a, dtype=np.float32) + vb = np.array(b, dtype=np.float32) + denom = np.linalg.norm(va) * np.linalg.norm(vb) + return float(np.dot(va, vb) / denom) if denom > 1e-8 else 0.0 + + +def cosine_matrix( + queries: list[list[float]], docs: list[list[float]] +) -> np.ndarray: + """ + Returns shape (Q, D) cosine similarity matrix. + queries: list of Q query vectors + docs: list of D document vectors + """ + Q = np.array(queries, dtype=np.float32) + D = np.array(docs, dtype=np.float32) + nQ = np.linalg.norm(Q, axis=1, keepdims=True) + nD = np.linalg.norm(D, axis=1, keepdims=True) + Q_norm = Q / (nQ + 1e-8) + D_norm = D / (nD + 1e-8) + return Q_norm @ D_norm.T + + +def top_k_indices(scores: list[float], k: int) -> list[int]: + """Return indices of top-k scores in descending order.""" + indexed = sorted(enumerate(scores), key=lambda x: x[1], reverse=True) + return [i for i, _ in indexed[:k]] + + +def max_pooled_score(matrix: np.ndarray) -> np.ndarray: + """ + For multi-query scenarios: take max score per document across all queries. + matrix: shape (Q, D) + Returns: shape (D,) + """ + return matrix.max(axis=0) diff --git a/backend/services/voice/__init__.py b/backend/services/voice/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0ceef073274775664f7f9ae64fe3aeb6f6f76603 --- /dev/null +++ b/backend/services/voice/__init__.py @@ -0,0 +1,4 @@ +from .stt import WhisperSTT +from .tts import get_tts_provider + +__all__ = ["WhisperSTT", "get_tts_provider"] diff --git a/backend/services/voice/stt.py b/backend/services/voice/stt.py new file mode 100644 index 0000000000000000000000000000000000000000..0875ef7da986e2f93aa7c5bec0f4832857b97421 --- /dev/null +++ b/backend/services/voice/stt.py @@ -0,0 +1,83 @@ +""" +Speech-to-Text — Arabic audio transcription via Groq Whisper API. + +Groq provides whisper-large-v3 with excellent Arabic accuracy. +Falls back to OpenAI Whisper API if OPENAI_API_KEY is set and Groq fails. +""" +from __future__ import annotations +import io +import logging + +log = logging.getLogger("tebyan.voice.stt") + +# Supported audio MIME types → file extensions for Groq +_MIME_EXT = { + "audio/webm": "webm", + "audio/mp4": "mp4", + "audio/mpeg": "mp3", + "audio/ogg": "ogg", + "audio/wav": "wav", + "audio/x-wav": "wav", + "audio/flac": "flac", + "audio/m4a": "m4a", + "audio/x-m4a": "m4a", + "video/webm": "webm", # browsers often send audio as video/webm +} + +MAX_AUDIO_BYTES = 25 * 1024 * 1024 # 25 MB (Groq limit) + + +class WhisperSTT: + """ + Transcribes Arabic audio using Groq's hosted Whisper large-v3. + """ + + MODEL = "whisper-large-v3" + + def __init__(self, groq_api_key: str): + from groq import Groq + self._client = Groq(api_key=groq_api_key) + + def transcribe( + self, + audio_bytes: bytes, + mime_type: str = "audio/webm", + language: str = "ar", + prompt: str = "تقرير طبي، فحوصات مختبرية، تحاليل طبية", + ) -> str: + """ + Transcribe audio bytes to text. + prompt: Arabic context hint improves medical term recognition. + """ + if len(audio_bytes) > MAX_AUDIO_BYTES: + raise ValueError(f"Audio too large: {len(audio_bytes) / 1e6:.1f}MB > 25MB limit") + + ext = _MIME_EXT.get(mime_type, "webm") + filename = f"audio.{ext}" + + log.info("[stt] transcribing | mime=%s ext=%s size=%dKB lang=%s", + mime_type, ext, len(audio_bytes) // 1024, language) + + transcription = self._client.audio.transcriptions.create( + file=(filename, io.BytesIO(audio_bytes), mime_type), + model=self.MODEL, + language=language, + prompt=prompt, + response_format="verbose_json", + temperature=0.0, + ) + + text = transcription.text.strip() + log.info("[stt] result: %r (%.1fs)", text[:80], getattr(transcription, "duration", 0)) + return text + + def detect_language(self, audio_bytes: bytes, mime_type: str = "audio/webm") -> str: + """Detect the spoken language without full transcription.""" + ext = _MIME_EXT.get(mime_type, "webm") + result = self._client.audio.transcriptions.create( + file=(f"audio.{ext}", io.BytesIO(audio_bytes), mime_type), + model=self.MODEL, + response_format="verbose_json", + temperature=0.0, + ) + return getattr(result, "language", "ar") diff --git a/backend/services/voice/tts.py b/backend/services/voice/tts.py new file mode 100644 index 0000000000000000000000000000000000000000..76ed837ca1d97052aca088cb78115347d18ee989 --- /dev/null +++ b/backend/services/voice/tts.py @@ -0,0 +1,106 @@ +""" +Text-to-Speech — Arabic TTS with multiple provider support. + +Provider priority: + 1. Google Cloud TTS (if GOOGLE_TTS_KEY is set) — best Arabic quality + 2. gTTS (free, Google Translate TTS) — no key required + 3. ElevenLabs (if ELEVENLABS_KEY is set) — premium quality + +Output: MP3 bytes, ready to stream to browser. +""" +from __future__ import annotations +import io +import logging + +log = logging.getLogger("tebyan.voice.tts") + +MAX_TEXT_CHARS = 3000 # trim long responses before TTS + + +def _truncate(text: str, max_chars: int = MAX_TEXT_CHARS) -> str: + """Trim text to TTS-safe length, cutting at sentence boundary.""" + if len(text) <= max_chars: + return text + cut = text[:max_chars] + for sep in (".", "!", "?", "\n", "،", "؟"): + idx = cut.rfind(sep) + if idx > max_chars * 0.7: + return cut[:idx + 1] + return cut + + +class GoogleTTS: + """Google Cloud Text-to-Speech — WaveNet Arabic voices.""" + VOICE = "ar-XA-Wavenet-A" # Arabic female WaveNet + LANG = "ar-XA" + + def __init__(self, api_key: str): + self._key = api_key + + def synthesize(self, text: str) -> bytes: + import requests, base64 + text = _truncate(text) + payload = { + "input": {"text": text}, + "voice": {"languageCode": self.LANG, "name": self.VOICE}, + "audioConfig": {"audioEncoding": "MP3", "speakingRate": 0.9}, + } + url = f"https://texttospeech.googleapis.com/v1/text:synthesize?key={self._key}" + r = requests.post(url, json=payload, timeout=30) + r.raise_for_status() + return base64.b64decode(r.json()["audioContent"]) + + +class GTTSProvider: + """Free Google Translate TTS via gTTS library.""" + + def synthesize(self, text: str) -> bytes: + from gtts import gTTS + text = _truncate(text) + buf = io.BytesIO() + tts = gTTS(text=text, lang="ar", slow=False) + tts.write_to_fp(buf) + buf.seek(0) + return buf.read() + + +class ElevenLabsTTS: + """ElevenLabs TTS — premium multilingual quality.""" + MODEL = "eleven_multilingual_v2" + + def __init__(self, api_key: str, voice_id: str = "pNInz6obpgDQGcFmaJgB"): + self._key = api_key + self._voice_id = voice_id + + def synthesize(self, text: str) -> bytes: + import requests + text = _truncate(text) + url = f"https://api.elevenlabs.io/v1/text-to-speech/{self._voice_id}" + headers = {"xi-api-key": self._key, "Content-Type": "application/json"} + payload = { + "text": text, + "model_id": self.MODEL, + "voice_settings": {"stability": 0.5, "similarity_boost": 0.75}, + } + r = requests.post(url, headers=headers, json=payload, timeout=60) + r.raise_for_status() + return r.content + + +def get_tts_provider(google_tts_key: str = "", elevenlabs_key: str = ""): + """Return the best available TTS provider.""" + if google_tts_key: + log.info("[tts] using Google Cloud TTS") + return GoogleTTS(google_tts_key) + if elevenlabs_key: + log.info("[tts] using ElevenLabs TTS") + return ElevenLabsTTS(elevenlabs_key) + try: + import gtts # noqa: F401 + log.info("[tts] using gTTS (free)") + return GTTSProvider() + except ImportError: + raise RuntimeError( + "No TTS provider available. Install gTTS (`pip install gtts`) or set " + "GOOGLE_TTS_KEY / ELEVENLABS_KEY environment variables." + ) diff --git a/backend/start.sh b/backend/start.sh new file mode 100644 index 0000000000000000000000000000000000000000..dd78634f660456d8d32afed9741d2c64f1f49e73 --- /dev/null +++ b/backend/start.sh @@ -0,0 +1,2 @@ +#!/bin/sh +exec uvicorn main:app --host 0.0.0.0 --port "${PORT:-8000}" --workers 1 diff --git a/backend/supabase/rls_setup.sql b/backend/supabase/rls_setup.sql new file mode 100644 index 0000000000000000000000000000000000000000..849d4ce7e9836b7cbc49ed64f6a6bec08c77114a --- /dev/null +++ b/backend/supabase/rls_setup.sql @@ -0,0 +1,49 @@ +-- ============================================================ +-- Supabase RLS Setup — تبيان الطبي +-- Run this once in the Supabase SQL Editor (Project > SQL Editor) +-- ============================================================ + +-- ── 1. Enable RLS on protected tables ────────────────────── +ALTER TABLE analyses ENABLE ROW LEVEL SECURITY; +ALTER TABLE chat_messages ENABLE ROW LEVEL SECURITY; + +-- ── 2. analyses: backend service_role only ───────────────── +-- service_role key bypasses RLS automatically in Supabase. +-- The policies below block all anon/authenticated direct access, +-- forcing all reads/writes through the FastAPI backend. + +DROP POLICY IF EXISTS analyses_deny_anon ON analyses; +CREATE POLICY analyses_deny_anon + ON analyses + FOR ALL + TO anon, authenticated + USING (false); + +-- ── 3. chat_messages: backend service_role only ──────────── +DROP POLICY IF EXISTS chat_deny_anon ON chat_messages; +CREATE POLICY chat_deny_anon + ON chat_messages + FOR ALL + TO anon, authenticated + USING (false); + +-- ── 4. documents (KB): allow anon read for pgvector search ─ +-- The KB is public knowledge — anon read is fine. +-- Write is blocked (only ingest scripts use service_role). +ALTER TABLE documents ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS documents_anon_read ON documents; +CREATE POLICY documents_anon_read + ON documents + FOR SELECT + TO anon, authenticated + USING (true); + +-- Also allow the match_documents RPC to execute +-- (RPC security is controlled via SECURITY DEFINER on the function; +-- no extra policy needed if the function uses service_role context) + +-- ── 5. Verify ───────────────────────────────────────────── +SELECT schemaname, tablename, rowsecurity +FROM pg_tables +WHERE tablename IN ('analyses', 'chat_messages', 'documents'); diff --git a/backend/test_single.py b/backend/test_single.py deleted file mode 100644 index 1e8f46f0dfea3a169e6048803ba4eb46401c2329..0000000000000000000000000000000000000000 --- a/backend/test_single.py +++ /dev/null @@ -1,33 +0,0 @@ -import os, json -from dotenv import load_dotenv -load_dotenv(dotenv_path=r'D:\Project\.env') -os.environ['TRANSFORMERS_VERBOSITY'] = 'error' -os.environ['HF_HOME'] = r'D:\Project\model_cache' -from groq import Groq - -groq_client = Groq(api_key=os.getenv('GROQ_API_KEY')) - -prompt = ( - "انت خبير طبي. اكتب JSON فقط بالعربية عن تحليل الهيموجلوبين hemoglobin. " - "المعدل الطبيعي: ذكر 13.5-17.5 g/dL, انثى 12-15.5 g/dL. " - 'اجب بهذا التنسيق حرفيا: {"definition":"...","normal_values":"...","abnormal_causes":"...","symptoms_tips":"..."}' -) - -r = groq_client.chat.completions.create( - model="llama-3.1-8b-instant", - messages=[{"role": "user", "content": prompt}], - temperature=0.2, - max_tokens=1000, -) -raw = r.choices[0].message.content.strip() -print("RAW len:", len(raw)) -print("FIRST 200:", raw[:200]) - -try: - start = raw.index('{') - end = raw.rindex('}') + 1 - data = json.loads(raw[start:end]) - print("Keys:", list(data.keys())) - print("SUCCESS") -except Exception as e: - print("PARSE ERROR:", e) diff --git a/backend/training/evaluate_model.py b/backend/training/evaluate_model.py new file mode 100644 index 0000000000000000000000000000000000000000..c9deaae597cc87e8e89eb39fc1c8358c1efd1f7b --- /dev/null +++ b/backend/training/evaluate_model.py @@ -0,0 +1,188 @@ +""" +evaluate_model.py — evaluate fine-tuned LoRA model vs base on Arabic medical benchmark. + +Metrics: + - BLEU-4 (surface fluency) + - ROUGE-L (recall) + - BERTScore (semantic similarity, multilingual) + - Medical Term Coverage (% of expected medical terms in output) + - GPT/LLM-as-judge score (via Groq, 1-5 scale) + - Latency (tokens/sec) + +Usage: + python training/evaluate_model.py \ + --base_model core42/jais-13b-chat \ + --lora_adapter checkpoints/tebyan-medical-v1/lora_adapter \ + --val_data data/val.jsonl \ + --output_dir eval_results/ \ + --n_samples 50 \ + --use_llm_judge +""" +from __future__ import annotations + +import argparse +import json +import time +from pathlib import Path + + +def parse_args(): + p = argparse.ArgumentParser() + p.add_argument("--base_model", required=True) + p.add_argument("--lora_adapter", required=True) + p.add_argument("--val_data", default="data/val.jsonl") + p.add_argument("--output_dir", default="eval_results") + p.add_argument("--n_samples", type=int, default=50) + p.add_argument("--max_new_tokens",type=int, default=512) + p.add_argument("--use_llm_judge",action="store_true") + p.add_argument("--groq_key", default=None) + p.add_argument("--hf_token", default=None) + return p.parse_args() + + +def load_val(path: str, n: int) -> list[dict]: + import random + rows = [json.loads(l) for l in Path(path).read_text(encoding="utf-8").splitlines() if l.strip()] + random.shuffle(rows) + return rows[:n] + + +def generate(model, tokenizer, prompt: str, max_new_tokens: int = 512) -> tuple[str, float]: + import torch + inputs = tokenizer(prompt, return_tensors="pt").to(model.device) + t0 = time.perf_counter() + with torch.no_grad(): + output = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False, temperature=1.0) + elapsed = time.perf_counter() - t0 + n_tokens = output.shape[1] - inputs["input_ids"].shape[1] + text = tokenizer.decode(output[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True) + return text.strip(), n_tokens / elapsed + + +def compute_metrics(reference: str, hypothesis: str) -> dict: + metrics: dict = {} + + # BLEU-4 + try: + from nltk.translate.bleu_score import sentence_bleu, SmoothingFunction + import re + ref_toks = re.findall(r"\w+", reference.lower()) + hyp_toks = re.findall(r"\w+", hypothesis.lower()) + sf = SmoothingFunction().method1 + metrics["bleu4"] = round(sentence_bleu([ref_toks], hyp_toks, weights=(0.25,)*4, smoothing_function=sf), 4) + except Exception: + metrics["bleu4"] = None + + # ROUGE-L + try: + from rouge_score import rouge_scorer + scorer = rouge_scorer.RougeScorer(["rougeL"], use_stemmer=False) + scores = scorer.score(reference, hypothesis) + metrics["rougeL"] = round(scores["rougeL"].fmeasure, 4) + except Exception: + metrics["rougeL"] = None + + # Medical term coverage + MEDICAL_TERMS_AR = [ + "هيموجلوبين", "كريات", "صفائح", "سكر", "كوليسترول", "كرياتينين", + "تقييم", "توصيات", "مرتفع", "منخفض", "طبيعي", "مراجعة الطبيب", + ] + found = sum(1 for t in MEDICAL_TERMS_AR if t in hypothesis) + metrics["medical_term_coverage"] = round(found / len(MEDICAL_TERMS_AR), 3) + + return metrics + + +def llm_judge(reference: str, hypothesis: str, groq_key: str) -> float: + """Score the hypothesis vs reference using Groq LLM-as-judge (1-5).""" + from groq import Groq + client = Groq(api_key=groq_key) + prompt = f"""أنت خبير طبي. قيّم جودة الإجابة الطبية مقارنة بالإجابة المرجعية. +أعطِ درجة من 1 إلى 5 (5 = ممتاز، 1 = ضعيف جداً). +أجب بالدرجة فقط كرقم. + +المرجع: {reference[:500]} +الإجابة المُقيَّمة: {hypothesis[:500]} +الدرجة:""" + try: + resp = client.chat.completions.create( + model="llama-3.1-8b-instant", + messages=[{"role": "user", "content": prompt}], + temperature=0, max_tokens=5, + ) + return float(resp.choices[0].message.content.strip()) + except Exception: + return 0.0 + + +def main(): + args = parse_args() + + import torch + from transformers import AutoTokenizer, AutoModelForCausalLM + from peft import PeftModel + + print(f"[eval] loading base model: {args.base_model}") + tokenizer = AutoTokenizer.from_pretrained(args.base_model, token=args.hf_token, trust_remote_code=True) + base_model = AutoModelForCausalLM.from_pretrained( + args.base_model, device_map="auto", torch_dtype=torch.bfloat16, + token=args.hf_token, trust_remote_code=True, + ) + + print(f"[eval] loading LoRA adapter: {args.lora_adapter}") + lora_model = PeftModel.from_pretrained(base_model, args.lora_adapter) + lora_model.eval() + + val_data = load_val(args.val_data, args.n_samples) + print(f"[eval] evaluating {len(val_data)} samples...") + + results = {"base": [], "lora": []} + + for i, ex in enumerate(val_data): + from training.train_lora import format_prompt + prompt = format_prompt({**ex, "output": ""}) # exclude output for generation + ref = ex.get("output", "") + + # Base model + hyp_base, tps_base = generate(base_model, tokenizer, prompt, args.max_new_tokens) + m_base = compute_metrics(ref, hyp_base) + m_base["tokens_per_sec"] = round(tps_base, 1) + + # LoRA model + hyp_lora, tps_lora = generate(lora_model, tokenizer, prompt, args.max_new_tokens) + m_lora = compute_metrics(ref, hyp_lora) + m_lora["tokens_per_sec"] = round(tps_lora, 1) + + if args.use_llm_judge and args.groq_key: + m_base["llm_judge"] = llm_judge(ref, hyp_base, args.groq_key) + m_lora["llm_judge"] = llm_judge(ref, hyp_lora, args.groq_key) + + results["base"].append(m_base) + results["lora"].append(m_lora) + + if (i + 1) % 10 == 0: + print(f"[eval] {i+1}/{len(val_data)}") + + # Aggregate + def avg(lst, key): + vals = [x[key] for x in lst if x.get(key) is not None] + return round(sum(vals) / len(vals), 4) if vals else None + + keys = ["bleu4", "rougeL", "medical_term_coverage", "tokens_per_sec", "llm_judge"] + summary = { + "base": {k: avg(results["base"], k) for k in keys}, + "lora": {k: avg(results["lora"], k) for k in keys}, + "n_samples": len(val_data), + } + print("\n[eval] Summary:") + print(json.dumps(summary, indent=2, ensure_ascii=False)) + + out = Path(args.output_dir) + out.mkdir(parents=True, exist_ok=True) + (out / "eval_summary.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False)) + (out / "eval_details.json").write_text(json.dumps(results, indent=2, ensure_ascii=False)) + print(f"[eval] results saved → {out}/") + + +if __name__ == "__main__": + main() diff --git a/backend/training/prepare_dataset.py b/backend/training/prepare_dataset.py new file mode 100644 index 0000000000000000000000000000000000000000..e10be8a50531d553ad7841ddeab5184b55a42aa0 --- /dev/null +++ b/backend/training/prepare_dataset.py @@ -0,0 +1,218 @@ +""" +prepare_dataset.py — formats Arabic medical reports for LoRA fine-tuning. + +Input sources: + 1. Supabase analyses table (via REST API) + 2. Local JSON files in data/raw/ + 3. Synthetic examples from few_shot_examples.json + +Output: data/train.jsonl, data/val.jsonl (Alpaca-format instruction tuning) + +Usage: + python training/prepare_dataset.py \ + --source supabase \ + --output_dir data/ \ + --val_split 0.1 \ + --min_findings 3 +""" +from __future__ import annotations + +import argparse +import json +import os +import random +from pathlib import Path +from typing import Iterator + +# ── Instruction templates ────────────────────────────────────────────────── + +_SYSTEM_MSG = ( + "أنت طبيب مختبر خبير متخصص في تفسير التحاليل الطبية باللغة العربية. " + "تقدم تفسيراً دقيقاً وواضحاً للنتائج المختبرية مع التوصيات المناسبة." +) + +_INSTRUCTION_TEMPLATE = ( + "فسّر نتائج التحاليل الطبية التالية وقدم تقييماً شاملاً:\n\n" + "النتائج:\n{findings}\n\n" + "اشرح:\n1. التقييم العام للحالة\n2. القيم غير الطبيعية وأسبابها المحتملة\n" + "3. التوصيات العملية للمريض" +) + +_OUTPUT_TEMPLATE = ( + "**التقييم العام:**\n{general}\n\n" + "**القيم غير الطبيعية:**\n{abnormal}\n\n" + "**التوصيات:**\n{tips}" +) + + +# ── Data source loaders ──────────────────────────────────────────────────── + +def load_from_supabase(limit: int = 5000) -> Iterator[dict]: + """Stream analyses from Supabase REST API.""" + from dotenv import load_dotenv + load_dotenv() + url = os.getenv("SUPABASE_URL", "") + key = os.getenv("SUPABASE_KEY", "") + if not url or not key: + print("[prepare] SUPABASE_URL/KEY not set — skipping Supabase source") + return + + import requests + headers = {"apikey": key, "Authorization": f"Bearer {key}"} + offset = 0 + batch = 100 + + while offset < limit: + r = requests.get( + f"{url}/rest/v1/analyses", + headers=headers, + params={"select": "findings,report,summary", "limit": batch, "offset": offset}, + timeout=15, + ) + r.raise_for_status() + rows = r.json() + if not rows: + break + for row in rows: + yield row + offset += batch + print(f"[prepare] loaded {offset} rows from Supabase...") + + +def load_from_json(data_dir: str) -> Iterator[dict]: + """Load analyses from local JSON files.""" + for path in Path(data_dir).glob("**/*.json"): + try: + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, list): + yield from data + elif isinstance(data, dict): + yield data + except Exception as e: + print(f"[prepare] skip {path}: {e}") + + +# ── Example formatter ────────────────────────────────────────────────────── + +def _format_findings(findings: list[dict]) -> str: + lines = [] + for f in findings: + status_ar = {"high": "مرتفع", "low": "منخفض", "normal": "طبيعي"}.get(f.get("status", ""), "") + line = f" • {f.get('name','')}: {f.get('value','')} {f.get('unit','')} (المعدل: {f.get('range','')}) — {status_ar}" + lines.append(line) + return "\n".join(lines) + + +def _format_abnormal(details: list[dict]) -> str: + lines = [] + for d in details: + lines.append(f" • {d.get('اسم_الفحص','')}: {d.get('الشرح','')}") + return "\n".join(lines) if lines else "لا توجد قيم غير طبيعية بارزة" + + +def _format_tips(tips) -> str: + if isinstance(tips, list): + if tips and isinstance(tips[0], dict): + # tips_categorized + lines = [] + for cat in tips: + lines.append(f"**{cat.get('category','')}:**") + for t in cat.get("tips", []): + lines.append(f" - {t}") + return "\n".join(lines) + return "\n".join(f" - {t}" for t in tips) + return str(tips) + + +def row_to_example(row: dict, min_findings: int = 2) -> dict | None: + """Convert a DB row to an Alpaca-format training example.""" + findings = row.get("findings") or [] + report = row.get("report") or {} + + if len(findings) < min_findings: + return None + if not report.get("general"): + return None + + abnormal = [f for f in findings if f.get("status") != "normal"] + details = report.get("abnormal_details") or [] + tips = report.get("tips_categorized") or report.get("tips") or [] + + instruction = _INSTRUCTION_TEMPLATE.format(findings=_format_findings(findings)) + output = _OUTPUT_TEMPLATE.format( + general=report.get("general", ""), + abnormal=_format_abnormal(details), + tips=_format_tips(tips), + ) + + return { + "system": _SYSTEM_MSG, + "instruction": instruction, + "input": "", + "output": output, + "metadata": { + "finding_count": len(findings), + "abnormal_count": len(abnormal), + }, + } + + +# ── Main ─────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--source", default="json", choices=["supabase", "json", "both"]) + parser.add_argument("--data_dir", default="data/raw") + parser.add_argument("--output_dir", default="data") + parser.add_argument("--val_split", type=float, default=0.1) + parser.add_argument("--min_findings",type=int, default=2) + parser.add_argument("--max_examples",type=int, default=10000) + parser.add_argument("--seed", type=int, default=42) + args = parser.parse_args() + + random.seed(args.seed) + examples: list[dict] = [] + + if args.source in ("supabase", "both"): + for row in load_from_supabase(limit=args.max_examples): + ex = row_to_example(row, args.min_findings) + if ex: + examples.append(ex) + + if args.source in ("json", "both"): + for row in load_from_json(args.data_dir): + ex = row_to_example(row, args.min_findings) + if ex: + examples.append(ex) + + print(f"[prepare] total examples before dedup: {len(examples)}") + examples = examples[:args.max_examples] + random.shuffle(examples) + + split = max(1, int(len(examples) * args.val_split)) + val_data = examples[:split] + train_data= examples[split:] + + out = Path(args.output_dir) + out.mkdir(parents=True, exist_ok=True) + + for name, data in [("train", train_data), ("val", val_data)]: + path = out / f"{name}.jsonl" + with open(path, "w", encoding="utf-8") as f: + for ex in data: + f.write(json.dumps(ex, ensure_ascii=False) + "\n") + print(f"[prepare] wrote {len(data)} examples → {path}") + + # Write dataset stats + stats = { + "total": len(examples), + "train": len(train_data), + "val": len(val_data), + "avg_findings": sum(e["metadata"]["finding_count"] for e in examples) / max(len(examples), 1), + } + (out / "dataset_stats.json").write_text(json.dumps(stats, indent=2, ensure_ascii=False)) + print(f"[prepare] stats: {stats}") + + +if __name__ == "__main__": + main() diff --git a/backend/training/train_lora.py b/backend/training/train_lora.py new file mode 100644 index 0000000000000000000000000000000000000000..e68c9d7f2f7449452b4ab08cd8dc758e5f49bc7e --- /dev/null +++ b/backend/training/train_lora.py @@ -0,0 +1,210 @@ +""" +train_lora.py — LoRA fine-tuning for Arabic medical report analysis. + +Supports: + - core42/jais-13b-chat (Arabic-English bilingual) + - meta-llama/Llama-3.2-1B-Instruct (lightweight) + - meta-llama/Llama-3.1-8B-Instruct (production quality) + - inceptionai/jais-adapted-7b-chat + +Requirements: + pip install transformers peft datasets accelerate bitsandbytes trl + +Usage: + python training/train_lora.py \ + --model_name core42/jais-13b-chat \ + --data_dir data/ \ + --output_dir checkpoints/jais-medical-v1 \ + --num_epochs 3 \ + --batch_size 2 \ + --lora_r 16 \ + --lora_alpha 32 \ + --load_4bit + +Hardware: Minimum 16GB VRAM (A100/H100 recommended for Jais-13B). +For smaller GPUs: use --load_4bit --gradient_checkpointing +""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser() + p.add_argument("--model_name", default="meta-llama/Llama-3.2-1B-Instruct") + p.add_argument("--data_dir", default="data") + p.add_argument("--output_dir", default="checkpoints/tebyan-medical-v1") + p.add_argument("--num_epochs", type=int, default=3) + p.add_argument("--batch_size", type=int, default=2) + p.add_argument("--grad_accum", type=int, default=4) + p.add_argument("--lr", type=float, default=2e-4) + p.add_argument("--max_seq_len", type=int, default=2048) + p.add_argument("--lora_r", type=int, default=16) + p.add_argument("--lora_alpha", type=int, default=32) + p.add_argument("--lora_dropout", type=float, default=0.05) + p.add_argument("--lora_target", nargs="+", default=["q_proj", "v_proj", "k_proj", "o_proj"]) + p.add_argument("--load_4bit", action="store_true") + p.add_argument("--load_8bit", action="store_true") + p.add_argument("--gradient_checkpointing", action="store_true") + p.add_argument("--warmup_ratio", type=float, default=0.05) + p.add_argument("--save_steps", type=int, default=100) + p.add_argument("--eval_steps", type=int, default=100) + p.add_argument("--hf_token", default=os.getenv("HF_TOKEN", "")) + return p.parse_args() + + +def load_dataset(data_dir: str): + from datasets import Dataset + import pandas as pd + + def load_jsonl(path: Path) -> list[dict]: + if not path.exists(): + return [] + return [json.loads(l) for l in path.read_text(encoding="utf-8").splitlines() if l.strip()] + + train = load_jsonl(Path(data_dir) / "train.jsonl") + val = load_jsonl(Path(data_dir) / "val.jsonl") + + if not train: + raise FileNotFoundError( + f"No train.jsonl found in {data_dir}. Run prepare_dataset.py first." + ) + + print(f"[train] loaded {len(train)} train, {len(val)} val examples") + return Dataset.from_list(train), Dataset.from_list(val) + + +def format_prompt(example: dict) -> str: + """Convert Alpaca-format example to model-specific chat template.""" + system = example.get("system", "") + instr = example.get("instruction", "") + inp = example.get("input", "") + output = example.get("output", "") + full_input = f"{instr}\n{inp}".strip() if inp else instr + return ( + f"<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\n{system}" + f"<|eot_id|><|start_header_id|>user<|end_header_id|>\n\n{full_input}" + f"<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n{output}<|eot_id|>" + ) + + +def main(): + args = parse_args() + + # ── Imports (deferred so script is importable without GPU) ────────────── + import torch + from transformers import ( + AutoTokenizer, AutoModelForCausalLM, + TrainingArguments, BitsAndBytesConfig, + ) + from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training + from trl import SFTTrainer + + print(f"[train] model={args.model_name} device={torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'cpu'}") + + # ── Quantization config ─────────────────────────────────────────────── + bnb_config = None + if args.load_4bit: + bnb_config = BitsAndBytesConfig( + load_in_4bit=True, + bnb_4bit_quant_type="nf4", + bnb_4bit_compute_dtype=torch.bfloat16, + bnb_4bit_use_double_quant=True, + ) + elif args.load_8bit: + bnb_config = BitsAndBytesConfig(load_in_8bit=True) + + # ── Load tokenizer + model ──────────────────────────────────────────── + tokenizer = AutoTokenizer.from_pretrained( + args.model_name, token=args.hf_token or None, trust_remote_code=True + ) + if tokenizer.pad_token is None: + tokenizer.pad_token = tokenizer.eos_token + + model = AutoModelForCausalLM.from_pretrained( + args.model_name, + quantization_config=bnb_config, + device_map="auto", + token=args.hf_token or None, + trust_remote_code=True, + torch_dtype=torch.bfloat16 if not bnb_config else None, + ) + + if bnb_config: + model = prepare_model_for_kbit_training(model) + + if args.gradient_checkpointing: + model.gradient_checkpointing_enable() + + # ── LoRA config ─────────────────────────────────────────────────────── + lora_config = LoraConfig( + r=args.lora_r, + lora_alpha=args.lora_alpha, + target_modules=args.lora_target, + lora_dropout=args.lora_dropout, + bias="none", + task_type="CAUSAL_LM", + ) + model = get_peft_model(model, lora_config) + model.print_trainable_parameters() + + # ── Dataset ──────────────────────────────────────────────────────────── + train_ds, val_ds = load_dataset(args.data_dir) + + # ── Training arguments ──────────────────────────────────────────────── + training_args = TrainingArguments( + output_dir=args.output_dir, + num_train_epochs=args.num_epochs, + per_device_train_batch_size=args.batch_size, + per_device_eval_batch_size=args.batch_size, + gradient_accumulation_steps=args.grad_accum, + learning_rate=args.lr, + warmup_ratio=args.warmup_ratio, + lr_scheduler_type="cosine", + save_strategy="steps", + save_steps=args.save_steps, + evaluation_strategy="steps", + eval_steps=args.eval_steps, + logging_steps=10, + load_best_model_at_end=True, + metric_for_best_model="eval_loss", + fp16=not args.load_4bit, + bf16=False, + report_to="none", + dataloader_num_workers=0, + group_by_length=True, + ) + + # ── Trainer ──────────────────────────────────────────────────────────── + trainer = SFTTrainer( + model=model, + args=training_args, + train_dataset=train_ds, + eval_dataset=val_ds if val_ds else None, + tokenizer=tokenizer, + formatting_func=format_prompt, + max_seq_length=args.max_seq_len, + dataset_text_field=None, + ) + + print(f"[train] starting training | epochs={args.num_epochs}") + trainer.train() + + # ── Save ─────────────────────────────────────────────────────────────── + out = Path(args.output_dir) + model.save_pretrained(out / "lora_adapter") + tokenizer.save_pretrained(out / "lora_adapter") + print(f"[train] LoRA adapter saved → {out / 'lora_adapter'}") + + # Save training config + (out / "training_config.json").write_text( + json.dumps(vars(args), indent=2, ensure_ascii=False) + ) + print("[train] Done.") + + +if __name__ == "__main__": + main() diff --git a/backend_log.txt b/backend_log.txt deleted file mode 100644 index 4e29f43140bd9324ddf65c654440ca22e032993a..0000000000000000000000000000000000000000 --- a/backend_log.txt +++ /dev/null @@ -1,127 +0,0 @@ -D:\Project\backend\main.py:14: FutureWarning: - -All support for the `google.generativeai` package has ended. It will no longer be receiving -updates or bug fixes. Please switch to the `google.genai` package as soon as possible. -See README for more details: - -https://github.com/google-gemini/deprecated-generative-ai-python/blob/main/README.md - - import google.generativeai as genai -INFO: Started server process [22808] -INFO: Waiting for application startup. -INFO: Application startup complete. -INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) -INFO: 127.0.0.1:56928 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:56938 - "GET /api/health HTTP/1.1" 200 OK -Using CPU. Note: This module is much faster with a GPU. -Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads. - Loading weights: 0%| | 0/103 [00:003.90-<11.00', 'unit': '10*3/uL', 'status': 'normal'}, {'name': '', 'value': '46', 'range': '>30-<70', 'unit': '%', 'status': 'normal'}, {'name': ' ()', 'value': '5', 'range': '>1-<12', 'unit': '%', 'status': 'normal'}, {'name': ' ()', 'value': '0', 'range': '>0-<2', 'unit': '%', 'status': 'normal'}, {'name': ' ', 'value': '41', 'range': '>23-<60', 'unit': '%', 'status': 'normal'}, {'name': ' ()', 'value': '9', 'range': '>4-<12', 'unit': '%', 'status': 'normal'}, {'name': ' ()', 'value': '2.42', 'range': '>1.35-<7.50', 'unit': '10*3/uL', 'status': 'normal'}, {'name': ' ()', 'value': '0.24', 'range': '>0.25-<1.00', 'unit': '10*3/uL', 'status': 'low'}, {'name': ' ()', 'value': '0.01', 'range': '>0.01-<0.10', 'unit': '10*3/uL', 'status': 'normal'}, {'name': ' ()', 'value': '2.17', 'range': '>1.50-<4.30', 'unit': '10*3/uL', 'status': 'normal'}, {'name': ' ()', 'value': '0.45', 'range': '>0.25-<1.00', 'unit': '10*3/uL', 'status': 'normal'}, {'name': 'NRBC % BY AUTOMATED COUNT', 'value': '0.00', 'range': '<=0.00', 'unit': '%', 'status': 'normal'}, {'name': 'ABSOLUTE NRBCS', 'value': '0.00', 'range': '', 'unit': '10*9/L', 'status': 'normal'}, {'name': ' ', 'value': '4.68', 'range': '>3.90-<4.60', 'unit': '10*6/L', 'status': 'high'}, {'name': '', 'value': '13.5', 'range': '>11.0-<16.0', 'unit': 'g/dL', 'status': 'normal'}, {'name': '', 'value': '43', 'range': '>32-<47', 'unit': '%', 'status': 'normal'}, {'name': ' ', 'value': '90.7', 'range': '>75.0-<95.0', 'unit': 'fL', 'status': 'normal'}, {'name': ' ', 'value': '28.8', 'range': '>24.0-<30.0', 'unit': 'pg', 'status': 'normal'}, {'name': ' ', 'value': '32', 'range': '>31-<37', 'unit': 'g/dL', 'status': 'normal'}, {'name': ' ', 'value': '13.3', 'range': '>11.0-<15.0', 'unit': '%', 'status': 'normal'}, {'name': ' ', 'value': '282', 'range': '150-450', 'unit': '10*3/uL', 'status': 'high'}, {'name': ' ', 'value': '10.3', 'range': '>6.3-<11.2', 'unit': 'fL', 'status': 'normal'}, {'name': ' ', 'value': '1.1740', 'range': '0.3500-4.9400', 'unit': 'mIU/L', 'status': 'high'}, {'name': ' ', 'value': '31.00', 'range': 'DETECTION LIMIT <= 0', 'unit': 'nmol/l', 'status': 'normal'}, {'name': '', 'value': '13.00', 'range': '4.43-204.00', 'unit': 'ug/l', 'status': 'high'}, {'name': ' 12', 'value': '285.00', 'range': '138.00-652.00', 'unit': 'pmol/l', 'status': 'high'}] -[DEBUG] abnormal count: 6 -[ERROR] report JSON failed: 429 You exceeded your current quota, please check your plan and billing details. For more information on this error, head to: https://ai.google.dev/gemini-api/docs/rate-limits. To monitor your current usage, head to: https://ai.dev/rate-limit. -* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_requests, limit: 20, model: gemini-2.5-flash -Please retry in 31.611703536s. [links { - description: "Learn more about Gemini API quotas" - url: "https://ai.google.dev/gemini-api/docs/rate-limits" -} -, violations { - quota_metric: "generativelanguage.googleapis.com/generate_content_free_tier_requests" - quota_id: "GenerateRequestsPerDayPerProjectPerModel-FreeTier" - quota_dimensions { - key: "model" - value: "gemini-2.5-flash" - } - quota_dimensions { - key: "location" - value: "global" - } - quota_value: 20 -} -, retry_delay { - seconds: 31 -} -] -INFO: 127.0.0.1:56962 - "POST /api/analyze HTTP/1.1" 200 OK -[DEBUG] findings count: 15 -[DEBUG] findings: [{'name': 'count', 'value': '4.94', 'range': '3.8 - 4.8', 'unit': 'x10^6/uL', 'status': 'high'}, {'name': 'Hemoglobin', 'value': '13.9', 'range': '12 - 16', 'unit': 'g/dl', 'status': 'normal'}, {'name': 'MCV', 'value': '86.2', 'range': '76 - 97', 'unit': 'fl', 'status': 'normal'}, {'name': 'MCH', 'value': '28.1', 'range': '27 - 32', 'unit': 'Pg', 'status': 'normal'}, {'name': 'MCHC', 'value': '32.6', 'range': '31.8 - 35.4', 'unit': 'g/dl', 'status': 'normal'}, {'name': 'Platelets', 'value': '310', 'range': '150 - 450', 'unit': '10^3/uL', 'status': 'normal'}, {'name': 'Lymphocytes%', 'value': '33.9', 'range': '15 - 45', 'unit': '', 'status': 'normal'}, {'name': 'Monocytes%', 'value': '7.7', 'range': '0 - 8', 'unit': '', 'status': 'normal'}, {'name': 'Eosinophils%', 'value': '7.3', 'range': '0 - 4', 'unit': '', 'status': 'high'}, {'name': 'Basophils%', 'value': '0.5', 'range': '0 - 2', 'unit': '', 'status': 'normal'}, {'name': 'Neutrophils#', 'value': '4.9', 'range': '2 - 7', 'unit': '10^3/uL', 'status': 'normal'}, {'name': 'Lymphocytes#', 'value': '3.3', 'range': '0.8 - 4', 'unit': '10^3/uL', 'status': 'normal'}, {'name': 'Monocytes#', 'value': '0.7', 'range': '0.12 - 1.2', 'unit': '10^3/uL', 'status': 'normal'}, {'name': 'Eosinophils#', 'value': '0.7', 'range': '0.04 - 0.5', 'unit': '10^3/uL', 'status': 'high'}, {'name': 'Basophils#', 'value': '0.0', 'range': '0 - 0.1', 'unit': '10^3/uL', 'status': 'normal'}] -[DEBUG] abnormal count: 3 -[ERROR] report JSON failed: 429 You exceeded your current quota, please check your plan and billing details. For more information on this error, head to: https://ai.google.dev/gemini-api/docs/rate-limits. To monitor your current usage, head to: https://ai.dev/rate-limit. -* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_requests, limit: 20, model: gemini-2.5-flash -Please retry in 8.616633164s. [links { - description: "Learn more about Gemini API quotas" - url: "https://ai.google.dev/gemini-api/docs/rate-limits" -} -, violations { - quota_metric: "generativelanguage.googleapis.com/generate_content_free_tier_requests" - quota_id: "GenerateRequestsPerDayPerProjectPerModel-FreeTier" - quota_dimensions { - key: "model" - value: "gemini-2.5-flash" - } - quota_dimensions { - key: "location" - value: "global" - } - quota_value: 20 -} -, retry_delay { - seconds: 8 -} -] -INFO: 127.0.0.1:57136 - "POST /api/analyze HTTP/1.1" 200 OK -[DEBUG] findings count: 15 -[DEBUG] findings: [{'name': 'count', 'value': '4.94', 'range': '3.8 - 4.8', 'unit': 'x10^6/uL', 'status': 'high'}, {'name': 'Hemoglobin', 'value': '13.9', 'range': '12 - 16', 'unit': 'g/dl', 'status': 'normal'}, {'name': 'MCV', 'value': '86.2', 'range': '76 - 97', 'unit': 'fl', 'status': 'normal'}, {'name': 'MCH', 'value': '28.1', 'range': '27 - 32', 'unit': 'Pg', 'status': 'normal'}, {'name': 'MCHC', 'value': '32.6', 'range': '31.8 - 35.4', 'unit': 'g/dl', 'status': 'normal'}, {'name': 'Platelets', 'value': '310', 'range': '150 - 450', 'unit': '10^3/uL', 'status': 'normal'}, {'name': 'Lymphocytes%', 'value': '33.9', 'range': '15 - 45', 'unit': '', 'status': 'normal'}, {'name': 'Monocytes%', 'value': '7.7', 'range': '0 - 8', 'unit': '', 'status': 'normal'}, {'name': 'Eosinophils%', 'value': '7.3', 'range': '0 - 4', 'unit': '', 'status': 'high'}, {'name': 'Basophils%', 'value': '0.5', 'range': '0 - 2', 'unit': '', 'status': 'normal'}, {'name': 'Neutrophils#', 'value': '4.9', 'range': '2 - 7', 'unit': '10^3/uL', 'status': 'normal'}, {'name': 'Lymphocytes#', 'value': '3.3', 'range': '0.8 - 4', 'unit': '10^3/uL', 'status': 'normal'}, {'name': 'Monocytes#', 'value': '0.7', 'range': '0.12 - 1.2', 'unit': '10^3/uL', 'status': 'normal'}, {'name': 'Eosinophils#', 'value': '0.7', 'range': '0.04 - 0.5', 'unit': '10^3/uL', 'status': 'high'}, {'name': 'Basophils#', 'value': '0.0', 'range': '0 - 0.1', 'unit': '10^3/uL', 'status': 'normal'}] -[DEBUG] abnormal count: 3 -[ERROR] report JSON failed: 429 You exceeded your current quota, please check your plan and billing details. For more information on this error, head to: https://ai.google.dev/gemini-api/docs/rate-limits. To monitor your current usage, head to: https://ai.dev/rate-limit. -* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_requests, limit: 20, model: gemini-2.5-flash -Please retry in 964.861876ms. [links { - description: "Learn more about Gemini API quotas" - url: "https://ai.google.dev/gemini-api/docs/rate-limits" -} -, violations { - quota_metric: "generativelanguage.googleapis.com/generate_content_free_tier_requests" - quota_id: "GenerateRequestsPerDayPerProjectPerModel-FreeTier" - quota_dimensions { - key: "model" - value: "gemini-2.5-flash" - } - quota_dimensions { - key: "location" - value: "global" - } - quota_value: 20 -} -, retry_delay { -} -] -INFO: 127.0.0.1:57170 - "POST /api/analyze HTTP/1.1" 200 OK -[DEBUG] findings count: 15 -[DEBUG] findings: [{'name': 'count', 'value': '4.94', 'range': '3.8 - 4.8', 'unit': 'x10^6/uL', 'status': 'high'}, {'name': 'Hemoglobin', 'value': '13.9', 'range': '12 - 16', 'unit': 'g/dl', 'status': 'normal'}, {'name': 'MCV', 'value': '86.2', 'range': '76 - 97', 'unit': 'fl', 'status': 'normal'}, {'name': 'MCH', 'value': '28.1', 'range': '27 - 32', 'unit': 'Pg', 'status': 'normal'}, {'name': 'MCHC', 'value': '32.6', 'range': '31.8 - 35.4', 'unit': 'g/dl', 'status': 'normal'}, {'name': 'Platelets', 'value': '310', 'range': '150 - 450', 'unit': '10^3/uL', 'status': 'normal'}, {'name': 'Lymphocytes%', 'value': '33.9', 'range': '15 - 45', 'unit': '', 'status': 'normal'}, {'name': 'Monocytes%', 'value': '7.7', 'range': '0 - 8', 'unit': '', 'status': 'normal'}, {'name': 'Eosinophils%', 'value': '7.3', 'range': '0 - 4', 'unit': '', 'status': 'high'}, {'name': 'Basophils%', 'value': '0.5', 'range': '0 - 2', 'unit': '', 'status': 'normal'}, {'name': 'Neutrophils#', 'value': '4.9', 'range': '2 - 7', 'unit': '10^3/uL', 'status': 'normal'}, {'name': 'Lymphocytes#', 'value': '3.3', 'range': '0.8 - 4', 'unit': '10^3/uL', 'status': 'normal'}, {'name': 'Monocytes#', 'value': '0.7', 'range': '0.12 - 1.2', 'unit': '10^3/uL', 'status': 'normal'}, {'name': 'Eosinophils#', 'value': '0.7', 'range': '0.04 - 0.5', 'unit': '10^3/uL', 'status': 'high'}, {'name': 'Basophils#', 'value': '0.0', 'range': '0 - 0.1', 'unit': '10^3/uL', 'status': 'normal'}] -[DEBUG] abnormal count: 3 -[ERROR] report JSON failed: 429 You exceeded your current quota, please check your plan and billing details. For more information on this error, head to: https://ai.google.dev/gemini-api/docs/rate-limits. To monitor your current usage, head to: https://ai.dev/rate-limit. -* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_requests, limit: 20, model: gemini-2.5-flash -Please retry in 25.341984155s. [links { - description: "Learn more about Gemini API quotas" - url: "https://ai.google.dev/gemini-api/docs/rate-limits" -} -, violations { - quota_metric: "generativelanguage.googleapis.com/generate_content_free_tier_requests" - quota_id: "GenerateRequestsPerDayPerProjectPerModel-FreeTier" - quota_dimensions { - key: "model" - value: "gemini-2.5-flash" - } - quota_dimensions { - key: "location" - value: "global" - } - quota_value: 20 -} -, retry_delay { - seconds: 25 -} -] -INFO: 127.0.0.1:57241 - "POST /api/analyze HTTP/1.1" 200 OK diff --git a/build_rag.py b/build_rag.py deleted file mode 100644 index b124f338678f8114fff3612aafb9babb8e99d110..0000000000000000000000000000000000000000 --- a/build_rag.py +++ /dev/null @@ -1,139 +0,0 @@ -import streamlit as st - -# --- إعدادات الصفحة --- -st.set_page_config(page_title="تبيان الطبي", layout="wide", initial_sidebar_state="collapsed") - -# --- محرك التصميم (CSS & HTML) لنسخ فيجما --- -st.markdown(f""" - - -""", unsafe_allow_html=True) - -# --- 1. الهيدر (Navbar) --- -st.markdown(""" - -""", unsafe_allow_html=True) - -# --- 2. عرض النتائج (Grid) --- -st.markdown('

نتائج التحليل

', unsafe_allow_html=True) - -# محاكاة لبيانات التحليل (هنا نربطها بمخرجات الـ OCR لاحقاً) -data = [ - {"name": "سكر الدم", "val": "95", "unit": "mg/dL", "status": "طبيعي", "type": "card-green"}, - {"name": "فيتامين د", "val": "18.5", "unit": "ng/mL", "status": "يحتاج متابعة", "type": "card-yellow"}, - {"name": "الهيموغلوبين", "val": "11.2", "unit": "g/dL", "status": "يحتاج علاج", "type": "card-red"} -] - -st.markdown('
', unsafe_allow_html=True) -cols = st.columns(3) -for i, item in enumerate(data): - with cols[i]: - st.markdown(f""" -
-
{item['name']}
-
{item['val']} {item['unit']}
-
{item['status']}
-
- """, unsafe_allow_html=True) -st.markdown('
', unsafe_allow_html=True) - -# --- 3. قسم التوصيات (Checklist) --- -st.markdown(""" -
-
توصيات (غذائية وسلوكية)
-
اتبع هذه الخطوات بناءً على نتائج مريضتنا رغد السبيعي لضمان أفضل صحة
- -
-
-
-
تناول اللحوم الحمراء والسبانخ والعدس 3 مرات أسبوعياً
-
المصدر: منظمة الصحة العالمية (WHO)
-
-
- -
-
-
-
التعرض لأشعة الشمس 15-20 دقيقة يومياً في الصباح الباكر
-
المصدر: جمعية الغذاء والدواء الأمريكية (FDA)
-
-
- -
-
-
-
شرب لترين من الماء يومياً لتحسين كفاءة التحليل القادم
-
المصدر: وزارة الصحة
-
-
-
-""", unsafe_allow_html=True) \ No newline at end of file diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000000000000000000000000000000000000..87512e99a029fa94745341223c00c6524facfe9a --- /dev/null +++ b/deploy.sh @@ -0,0 +1,137 @@ +#!/bin/bash +# ══════════════════════════════════════════════════════ +# تبيان الطبي — Server Setup & Deploy Script +# Run on Oracle Cloud ARM VM (Ubuntu 22.04) +# +# First time: bash deploy.sh setup +# Update code: bash deploy.sh deploy +# View logs: bash deploy.sh logs +# ══════════════════════════════════════════════════════ + +set -e +REPO_URL="${REPO_URL:-https://github.com/YOUR_USERNAME/tebyan-medical.git}" +APP_DIR="/opt/tebyan" +COMPOSE="docker compose -f docker-compose.prod.yml" + +# ── Colors ──────────────────────────────────────────── +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' +ok() { echo -e "${GREEN}✓ $1${NC}"; } +warn() { echo -e "${YELLOW}⚠ $1${NC}"; } +err() { echo -e "${RED}✗ $1${NC}"; exit 1; } + +# ══════════════════════════════════════════════════════ +case "${1:-help}" in + +# ── SETUP: run once on a fresh server ──────────────── +setup) + echo "━━━ تبيان الطبي — Server Setup ━━━" + + # Docker + if ! command -v docker &>/dev/null; then + curl -fsSL https://get.docker.com | sh + usermod -aG docker "$USER" + ok "Docker installed" + else + ok "Docker already installed" + fi + + # Docker Compose v2 + if ! docker compose version &>/dev/null; then + apt-get install -y docker-compose-plugin + ok "Docker Compose plugin installed" + fi + + # Firewall + ufw allow 22/tcp # SSH + ufw allow 80/tcp # HTTP + ufw allow 443/tcp # HTTPS + ufw --force enable + ok "Firewall configured (22, 80, 443)" + + # Clone repo + if [ ! -d "$APP_DIR" ]; then + git clone "$REPO_URL" "$APP_DIR" + ok "Repository cloned to $APP_DIR" + else + warn "Directory $APP_DIR already exists — skipping clone" + fi + + # .env + if [ ! -f "$APP_DIR/.env" ]; then + cp "$APP_DIR/.env.production.example" "$APP_DIR/.env" + warn "Created $APP_DIR/.env — FILL IN YOUR KEYS NOW:" + warn " nano $APP_DIR/.env" + warn "Then run: bash $APP_DIR/deploy.sh ssl" + else + ok ".env already exists" + fi + + echo "" + echo "Next steps:" + echo " 1. nano $APP_DIR/.env ← fill in your API keys" + echo " 2. DOMAIN=yourdomain.com EMAIL=you@example.com bash $APP_DIR/nginx/init-letsencrypt.sh" + echo " 3. bash $APP_DIR/deploy.sh deploy" + ;; + +# ── DEPLOY: pull latest and restart ────────────────── +deploy) + echo "━━━ Deploying تبيان الطبي ━━━" + cd "$APP_DIR" + + [ ! -f .env ] && err ".env not found. Run: bash deploy.sh setup" + + git pull origin main + ok "Code updated" + + $COMPOSE build --pull + ok "Images built" + + $COMPOSE up -d + ok "Containers started" + + echo "Waiting for health checks..." + sleep 10 + $COMPOSE ps + ;; + +# ── SSL: get Let's Encrypt cert ─────────────────────── +ssl) + [ -z "$DOMAIN" ] && err "Set DOMAIN=yourdomain.com before running" + [ -z "$EMAIL" ] && err "Set EMAIL=you@example.com before running" + cd "$APP_DIR" + DOMAIN="$DOMAIN" EMAIL="$EMAIL" bash nginx/init-letsencrypt.sh + ;; + +# ── LOGS ────────────────────────────────────────────── +logs) + cd "$APP_DIR" + $COMPOSE logs -f --tail=100 "${2:-}" + ;; + +# ── STATUS ──────────────────────────────────────────── +status) + cd "$APP_DIR" + $COMPOSE ps + echo "" + curl -s http://localhost/health | python3 -m json.tool 2>/dev/null || echo "Backend not reachable" + ;; + +# ── STOP ────────────────────────────────────────────── +stop) + cd "$APP_DIR" + $COMPOSE down + ok "All containers stopped" + ;; + +# ── HELP ────────────────────────────────────────────── +*) + echo "Usage: bash deploy.sh [setup|deploy|ssl|logs|status|stop]" + echo "" + echo " setup — Install Docker, clone repo, configure firewall" + echo " deploy — Pull latest code and restart containers" + echo " ssl — Get Let's Encrypt certificate (set DOMAIN= EMAIL= first)" + echo " logs — Follow container logs (optional: logs backend)" + echo " status — Show container status and health" + echo " stop — Stop all containers" + ;; +esac diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000000000000000000000000000000000000..3f9d220cbda17da9879f9d325d42cea8a64f7329 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,151 @@ +version: "3.9" + +# ══════════════════════════════════════════════════════ +# تبيان الطبي — Production Docker Compose +# Oracle Cloud Always Free ARM VM (4 CPU / 24 GB RAM) +# +# First-time HTTPS setup: +# bash nginx/init-letsencrypt.sh +# +# Deploy / update: +# docker compose -f docker-compose.prod.yml pull +# docker compose -f docker-compose.prod.yml up -d --build +# ══════════════════════════════════════════════════════ + +services: + + # ── Redis ──────────────────────────────────────────────────────────────────── + redis: + image: redis:7.2-alpine + restart: unless-stopped + command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru --save "" + expose: + - "6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + networks: + - tebyan_net + + # ── Backend (FastAPI) ──────────────────────────────────────────────────────── + backend: + build: + context: ./backend + dockerfile: Dockerfile + restart: unless-stopped + env_file: .env + environment: + ENVIRONMENT: production + PORT: "8000" + REDIS_URL: redis://redis:6379/0 + AUDIT_LOG_DIR: /app/logs/audit + HF_HOME: /app/model_cache + volumes: + - model_cache:/app/model_cache + - audit_logs:/app/logs + expose: + - "8000" + depends_on: + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 120s + deploy: + resources: + limits: + memory: 6G + cpus: "3.0" + reservations: + memory: 2G + networks: + - tebyan_net + + # ── Frontend (Next.js) ─────────────────────────────────────────────────────── + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL:-/} + NEXT_PUBLIC_SUPABASE_URL: ${SUPABASE_URL} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${SUPABASE_KEY} + restart: unless-stopped + environment: + NODE_ENV: production + NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL:-/} + NEXT_PUBLIC_SUPABASE_URL: ${SUPABASE_URL} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${SUPABASE_KEY} + expose: + - "3000" + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health-check || exit 0"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + deploy: + resources: + limits: + memory: 1G + cpus: "1.0" + networks: + - tebyan_net + + # ── Nginx reverse proxy + SSL ──────────────────────────────────────────────── + nginx: + image: nginx:1.27-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - certbot_www:/var/www/certbot:ro + - certbot_certs:/etc/letsencrypt:ro + depends_on: + - frontend + - backend + deploy: + resources: + limits: + memory: 128M + cpus: "0.25" + networks: + - tebyan_net + + # ── Certbot (Let's Encrypt) ────────────────────────────────────────────────── + certbot: + image: certbot/certbot:latest + volumes: + - certbot_www:/var/www/certbot + - certbot_certs:/etc/letsencrypt + # Renews automatically — runs every 12h, renews when <30 days left + entrypoint: > + /bin/sh -c "trap exit TERM; + while :; do + certbot renew --webroot -w /var/www/certbot --quiet; + sleep 12h & wait $${!}; + done" + +volumes: + model_cache: + audit_logs: + redis_data: + certbot_www: + certbot_certs: + +networks: + tebyan_net: + driver: bridge diff --git a/download_model.py b/download_model.py deleted file mode 100644 index cbb623e06ea810f8f342ccecea1af3537d390975..0000000000000000000000000000000000000000 --- a/download_model.py +++ /dev/null @@ -1,21 +0,0 @@ -import os -# إجبار النظام على استخدام القرص D للمساحة المؤقتة أيضاً -os.environ['HF_HOME'] = r'D:\Project\model_cache' -os.environ['TEMP'] = r'D:\Project\temp' -os.environ['TMP'] = r'D:\Project\temp' - -from sentence_transformers import SentenceTransformer - -# إنشاء مجلدات المؤقتة في D إذا لم تكن موجودة -os.makedirs(r'D:\Project\temp', exist_ok=True) - -# موديل صغير جداً وخفيف (22 ميجابايت فقط) -model_name = "paraphrase-MiniLM-L3-v2" - -print(f"جاري تحميل الموديل الصغير إلى القرص D...") - -try: - model = SentenceTransformer(model_name) - print("✅ تم التحميل بنجاح! المساحة لم تعد عائقاً.") -except Exception as e: - print(f"❌ حدث خطأ: {e}") \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..e985853ed84acf10f62f639a4733083229c5a55d --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..2774523136447580f37ee5b841bf001c8636e228 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,40 @@ +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* pnpm-lock.yaml* ./ +RUN if [ -f pnpm-lock.yaml ]; then \ + npm install -g pnpm && pnpm install --frozen-lockfile; \ + else \ + npm ci --legacy-peer-deps; \ + fi + +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ARG NEXT_PUBLIC_BACKEND_URL=/ +ARG NEXT_PUBLIC_SUPABASE_URL +ARG NEXT_PUBLIC_SUPABASE_ANON_KEY +ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL +ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL +ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/frontend/app/(auth)/forgot-password/page.tsx b/frontend/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9bfaf7170a099a2d8dbf21b32bcee0f34ed075f9 --- /dev/null +++ b/frontend/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,164 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { motion, AnimatePresence } from "framer-motion" +import { Mail, Sparkles, ChevronLeft, Send, CheckCircle2 } from "lucide-react" +import { toast } from "sonner" +import { useAuth } from "@/lib/auth-context" + +export default function ForgotPasswordPage() { + const { resetPassword } = useAuth() + const [email, setEmail] = useState("") + const [loading, setLoading] = useState(false) + const [sent, setSent] = useState(false) + const [error, setError] = useState("") + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + if (!email) { setError("يرجى إدخال بريدك الإلكتروني"); return } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { setError("صيغة البريد غير صحيحة"); return } + + setLoading(true) + const { error: err } = await resetPassword(email) + setLoading(false) + + if (err) { + toast.error("تعذّر إرسال البريد — تأكد من صحة العنوان وحاول مجدداً") + return + } + setSent(true) + } + + return ( +
+ + {/* Logo */} +
+ +
+ +
+ تبيان الطبي + +
+ +
+ + + {/* ── Sent state ──────────────────────────────────────── */} + {sent ? ( + +
+ +
+

تحقّق من بريدك

+

+ أرسلنا رابط إعادة تعيين كلمة المرور إلى: +

+

{email}

+

+ انقر على الرابط الموجود في البريد. الرابط صالح لمدة ساعة واحدة. + تحقّق من مجلد الرسائل غير المرغوب إن لم تجده. +

+ + + + العودة لتسجيل الدخول + +
+ ) : ( + + /* ── Form ────────────────────────────────────────────── */ + +
+
+ +
+

نسيت كلمة المرور؟

+

+ أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين +

+
+ +
+
+ +
+ + { setEmail(e.target.value); setError("") }} + className={`w-full pr-10 pl-4 py-3 rounded-2xl text-sm bg-input border transition-colors outline-none + focus:border-ring focus:ring-2 focus:ring-ring/20 placeholder:text-muted-foreground + ${error ? "border-destructive focus:border-destructive focus:ring-destructive/20" : "border-border/60"}`} + /> +
+ + {error && ( + {error} + )} + +
+ + + {loading ? ( + <> + + جارٍ الإرسال... + + ) : ( + <> + + إرسال رابط الاسترداد + + )} + +
+ +
+ + + العودة لتسجيل الدخول + +
+
+ )} + +
+
+
+
+ ) +} diff --git a/frontend/app/(auth)/layout.tsx b/frontend/app/(auth)/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..257127f51f9a88186779bfaed8e99f9f191cf987 --- /dev/null +++ b/frontend/app/(auth)/layout.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from "next" + +export const metadata: Metadata = { + title: "تبيان الطبي — الحساب", + description: "منصة الذكاء الاصطناعي لتحليل التقارير الطبية", +} + +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( +
+ {/* Ambient gradient blobs */} +
+
+
+
+ {/* Subtle dot grid */} + + + + + + + + +
+ + {children} +
+ ) +} diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a76ff317d7404bf0a388b16be7f47874501c44ca --- /dev/null +++ b/frontend/app/(auth)/login/page.tsx @@ -0,0 +1,286 @@ +"use client" + +import { useState, useEffect } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { motion, AnimatePresence } from "framer-motion" +import { + Eye, EyeOff, Mail, Lock, LogIn, + Sparkles, ShieldCheck, Brain, Microscope, ChevronLeft, +} from "lucide-react" +import { toast } from "sonner" +import { useAuth } from "@/lib/auth-context" + +const FEATURES = [ + { icon: Brain, text: "تحليل ذكي للتقارير الطبية بدقة علمية" }, + { icon: ShieldCheck, text: "خصوصية تامة — بياناتك لا تُشارك أبداً" }, + { icon: Microscope, text: "مرجعية طبية محدّثة لأكثر من 50 فحصاً" }, +] + +const fadeUp = { + hidden: { opacity: 0, y: 16 }, + show: (i: number) => ({ opacity: 1, y: 0, transition: { delay: i * 0.08, duration: 0.45, ease: [0.22, 1, 0.36, 1] } }), +} + +export default function LoginPage() { + const router = useRouter() + const { signIn, loading: authLoading } = useAuth() + + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [showPass, setShowPass] = useState(false) + const [loading, setLoading] = useState(false) + const [fieldErr, setFieldErr] = useState<{ email?: string; password?: string }>({}) + + // Show one-time messages from query string (verified, reset, error) + useEffect(() => { + const params = new URLSearchParams(window.location.search) + if (params.get("verified") === "1") toast.success("تم التحقق من بريدك الإلكتروني — يمكنك تسجيل الدخول الآن ✓") + if (params.get("reset") === "1") toast.success("تم تحديث كلمة المرور بنجاح ✓") + if (params.get("error")) toast.error("رابط التحقق غير صالح أو منتهي الصلاحية") + }, []) + + const validate = () => { + const err: typeof fieldErr = {} + if (!email) err.email = "البريد الإلكتروني مطلوب" + else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) err.email = "صيغة البريد غير صحيحة" + if (!password) err.password = "كلمة المرور مطلوبة" + else if (password.length < 6) err.password = "6 أحرف على الأقل" + setFieldErr(err) + return !Object.keys(err).length + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!validate()) return + setLoading(true) + const { error } = await signIn(email, password) + setLoading(false) + + if (error) { + const msg = error.message.includes("Invalid login") ? "البريد الإلكتروني أو كلمة المرور غير صحيحة" + : error.message.includes("Email not confirmed") ? "يرجى تأكيد بريدك الإلكتروني أولاً" + : error.message.includes("Too many") ? "محاولات كثيرة — يرجى الانتظار قليلاً" + : "حدث خطأ، يرجى المحاولة مرة أخرى" + toast.error(msg) + return + } + + const next = new URLSearchParams(window.location.search).get("next") || "/" + router.push(next) + router.refresh() + } + + return ( +
+ + {/* ── Left panel: Brand (desktop only) ─────────────────────────── */} + + {/* Panel gradient */} +
+
+ + {/* Logo */} +
+ +
+ +
+ تبيان الطبي + +
+ + {/* Headline + features */} +
+
+

+ منصتك الطبية +
+ الذكية والموثوقة +

+

+ حلّل تقاريرك المخبرية بفهم عميق مدعوم بأحدث تقنيات الذكاء الاصطناعي الطبي +

+
+ +
    + {FEATURES.map(({ icon: Icon, text }, i) => ( + +
    + +
    + {text} +
    + ))} +
+
+ +

+ © 2025 تبيان الطبي — جميع الحقوق محفوظة +

+ + + {/* ── Right panel: Form ─────────────────────────────────────────── */} +
+ + {/* Mobile logo */} +
+ +
+ +
+ تبيان الطبي + +
+ + {/* Glass card */} +
+ + {/* Header */} +
+

مرحباً بعودتك

+

سجّل دخولك للوصول إلى تحاليلك الطبية

+
+ +
+ + {/* Email */} +
+ +
+ + { setEmail(e.target.value); setFieldErr(p => ({ ...p, email: undefined })) }} + className={`w-full pr-10 pl-4 py-3 rounded-2xl text-sm bg-input border transition-colors outline-none + focus:border-ring focus:ring-2 focus:ring-ring/20 placeholder:text-muted-foreground + ${fieldErr.email ? "border-destructive focus:border-destructive focus:ring-destructive/20" : "border-border/60"}`} + /> +
+ + {fieldErr.email && ( + + {fieldErr.email} + + )} + +
+ + {/* Password */} +
+
+ + + نسيتِ كلمة المرور؟ + +
+
+ + { setPassword(e.target.value); setFieldErr(p => ({ ...p, password: undefined })) }} + className={`w-full pr-10 pl-10 py-3 rounded-2xl text-sm bg-input border transition-colors outline-none + focus:border-ring focus:ring-2 focus:ring-ring/20 placeholder:text-muted-foreground + ${fieldErr.password ? "border-destructive focus:border-destructive focus:ring-destructive/20" : "border-border/60"}`} + /> + +
+ + {fieldErr.password && ( + + {fieldErr.password} + + )} + +
+ + {/* Submit */} + + {loading ? ( + <> + + جارٍ تسجيل الدخول... + + ) : ( + <> + + تسجيل الدخول + + )} + +
+ + {/* Divider */} +
+
+
+
+
+ أو +
+
+ + {/* Register link */} +

+ ليس لديك حساب؟{" "} + + إنشاء حساب مجاني + +

+
+ + {/* Back to home */} +
+ + + العودة إلى الصفحة الرئيسية + +
+ +
+
+ ) +} diff --git a/frontend/app/(auth)/register/page.tsx b/frontend/app/(auth)/register/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..099c766a42a55eb51fb10c7d555caba2e50c9f50 --- /dev/null +++ b/frontend/app/(auth)/register/page.tsx @@ -0,0 +1,362 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { motion, AnimatePresence } from "framer-motion" +import { + Eye, EyeOff, Mail, Lock, User as UserIcon, + Sparkles, CheckCircle2, ChevronLeft, UserPlus, +} from "lucide-react" +import { toast } from "sonner" +import { useAuth } from "@/lib/auth-context" + +const STEPS_BENEFITS = [ + "تحليل فوري لتقاريرك المخبرية", + "شرح مبسّط لكل فحص", + "حفظ سجلّك الطبي بأمان", + "مقارنة التقارير عبر الزمن", +] + +export default function RegisterPage() { + const { signUp } = useAuth() + + const [fullName, setFullName] = useState("") + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [confirmPass, setConfirmPass] = useState("") + const [showPass, setShowPass] = useState(false) + const [showConfirm, setShowConfirm] = useState(false) + const [loading, setLoading] = useState(false) + const [verified, setVerified] = useState(false) // show email-sent state + const [fieldErr, setFieldErr] = useState>({}) + + const passwordStrength = (() => { + if (!password) return 0 + let score = 0 + if (password.length >= 8) score++ + if (/[A-Z]/.test(password)) score++ + if (/[0-9]/.test(password)) score++ + if (/[^A-Za-z0-9]/.test(password)) score++ + return score + })() + + const strengthLabel = ["ضعيفة جداً", "ضعيفة", "متوسطة", "قوية", "قوية جداً"][passwordStrength] + const strengthColor = ["bg-destructive", "bg-orange-500", "bg-warning", "bg-success", "bg-success"][passwordStrength] + + const validate = () => { + const err: Record = {} + if (!fullName.trim()) err.fullName = "الاسم مطلوب" + if (!email) err.email = "البريد الإلكتروني مطلوب" + else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) err.email = "صيغة البريد غير صحيحة" + if (!password) err.password = "كلمة المرور مطلوبة" + else if (password.length < 6) err.password = "6 أحرف على الأقل" + if (!confirmPass) err.confirmPass = "يرجى تأكيد كلمة المرور" + else if (password !== confirmPass) err.confirmPass = "كلمتا المرور غير متطابقتين" + setFieldErr(err) + return !Object.keys(err).length + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!validate()) return + setLoading(true) + const { error, needsVerification } = await signUp(email, password, fullName.trim()) + setLoading(false) + + if (error) { + const msg = error.message.includes("already registered") ? "هذا البريد الإلكتروني مسجّل مسبقاً" + : error.message.includes("Password should") ? "كلمة المرور يجب أن تكون 6 أحرف على الأقل" + : error.message + toast.error(msg) + return + } + + if (needsVerification) { + setVerified(true) + } else { + // Immediately logged in (email confirmation disabled) + window.location.href = "/" + } + } + + const clearErr = (key: string) => setFieldErr(p => { const c = { ...p }; delete c[key]; return c }) + + // ── Email sent state ────────────────────────────────────────────────────── + if (verified) { + return ( +
+ +
+ +
+

تحقّق من بريدك

+

+ أرسلنا رابط التفعيل إلى +

+

{email}

+

+ افتح البريد وانقر على الرابط لتفعيل حسابك. + تحقّق من مجلد الرسائل غير المرغوب إن لم تجده. +

+ + + العودة لتسجيل الدخول + +
+
+ ) + } + + // ── Registration form ───────────────────────────────────────────────────── + return ( +
+ + {/* Left panel: Benefits */} + +
+
+ +
+ +
+ +
+ تبيان الطبي + +
+ +
+
+

+ انضم مجاناً +
+ وابدأ تحليلاتك +

+

+ حساب واحد يمنحك وصولاً كاملاً إلى أدوات التحليل الطبي الذكي +

+
+ +
    + {STEPS_BENEFITS.map((text, i) => ( + + + {text} + + ))} +
+
+ +

+ © 2025 تبيان الطبي — جميع الحقوق محفوظة +

+ + + {/* Right panel: Form */} +
+ + {/* Mobile logo */} +
+ +
+ +
+ تبيان الطبي + +
+ + {/* Glass card */} +
+
+

إنشاء حساب جديد

+

مجاناً — لا يتطلب بطاقة ائتمانية

+
+ +
+ + {/* Full name */} + } + value={fullName} onChange={v => { setFullName(v); clearErr("fullName") }} + error={fieldErr.fullName} type="text" autoComplete="name" + /> + + {/* Email */} + } + value={email} onChange={v => { setEmail(v); clearErr("email") }} + error={fieldErr.email} type="email" autoComplete="email" dir="ltr" + /> + + {/* Password */} +
+ +
+ + { setPassword(e.target.value); clearErr("password") }} + className={inputCls(!!fieldErr.password)} + /> + +
+ {/* Strength bar */} + {password && ( + +
+ {[1, 2, 3, 4].map(n => ( +
+ ))} +
+

قوة كلمة المرور: {strengthLabel}

+ + )} + +
+ + {/* Confirm password */} +
+ +
+ + { setConfirmPass(e.target.value); clearErr("confirmPass") }} + className={inputCls(!!fieldErr.confirmPass)} + /> + +
+ +
+ + {/* Submit */} + + {loading ? ( + <> + + جارٍ إنشاء الحساب... + + ) : ( + <> + + إنشاء الحساب + + )} + + +

+ بالتسجيل توافق على{" "} + سياسة الخصوصية +

+ + +
+
+
أو
+
+ +

+ لديك حساب بالفعل؟{" "} + تسجيل الدخول +

+
+ +
+ + + العودة إلى الصفحة الرئيسية + +
+
+
+
+ ) +} + +// ── Shared helpers ──────────────────────────────────────────────────────────── + +function inputCls(hasError: boolean) { + return `w-full pr-10 pl-10 py-3 rounded-2xl text-sm bg-input border transition-colors outline-none + focus:border-ring focus:ring-2 focus:ring-ring/20 placeholder:text-muted-foreground + ${hasError ? "border-destructive focus:border-destructive focus:ring-destructive/20" : "border-border/60"}` +} + +function FieldError({ msg }: { msg?: string }) { + return ( + + {msg && ( + {msg} + )} + + ) +} + +function Field({ id, label, placeholder, icon, value, onChange, error, type = "text", autoComplete, dir }: { + id: string; label: string; placeholder: string; icon: React.ReactNode + value: string; onChange: (v: string) => void; error?: string + type?: string; autoComplete?: string; dir?: string +}) { + return ( +
+ +
+ + {icon} + + onChange(e.target.value)} + className={`w-full pr-10 pl-4 py-3 rounded-2xl text-sm bg-input border transition-colors outline-none + focus:border-ring focus:ring-2 focus:ring-ring/20 placeholder:text-muted-foreground + ${error ? "border-destructive focus:border-destructive focus:ring-destructive/20" : "border-border/60"}`} + /> +
+ +
+ ) +} diff --git a/frontend/app/(auth)/reset-password/page.tsx b/frontend/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0e6a8c590a869ff6d06b663a81579699c159d943 --- /dev/null +++ b/frontend/app/(auth)/reset-password/page.tsx @@ -0,0 +1,243 @@ +"use client" + +import { useState, useEffect } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { motion, AnimatePresence } from "framer-motion" +import { Eye, EyeOff, Lock, Sparkles, ShieldCheck, CheckCircle2 } from "lucide-react" +import { toast } from "sonner" +import { useAuth } from "@/lib/auth-context" +import { createClient } from "@/lib/supabase" + +export default function ResetPasswordPage() { + const router = useRouter() + const { updatePassword } = useAuth() + + const [password, setPassword] = useState("") + const [confirmPass, setConfirmPass] = useState("") + const [showPass, setShowPass] = useState(false) + const [showConfirm, setShowConfirm] = useState(false) + const [loading, setLoading] = useState(false) + const [ready, setReady] = useState(false) // recovery session confirmed + const [done, setDone] = useState(false) + const [fieldErr, setFieldErr] = useState<{ password?: string; confirmPass?: string }>({}) + + // Supabase sets the recovery session via cookie (auth/confirm route handles verifyOtp). + // We just need to verify the user is in a recovery session. + useEffect(() => { + const supabase = createClient() + // Check if we have a valid session (set by /auth/confirm after verifyOtp) + supabase.auth.getSession().then(({ data: { session } }) => { + if (session) { + setReady(true) + } else { + // No session — maybe user landed here directly without the email link + toast.error("رابط إعادة التعيين غير صالح أو منتهي الصلاحية") + setTimeout(() => router.push("/forgot-password"), 2500) + } + }) + + // Also listen for PASSWORD_RECOVERY event in case of hash-based flow + const { data: { subscription } } = supabase.auth.onAuthStateChange((event) => { + if (event === "PASSWORD_RECOVERY") setReady(true) + }) + return () => subscription.unsubscribe() + }, [router]) + + const passwordStrength = (() => { + if (!password) return 0 + let s = 0 + if (password.length >= 8) s++ + if (/[A-Z]/.test(password)) s++ + if (/[0-9]/.test(password)) s++ + if (/[^A-Za-z0-9]/.test(password)) s++ + return s + })() + + const strengthLabel = ["", "ضعيفة", "متوسطة", "قوية", "قوية جداً"][passwordStrength] + const strengthColor = ["", "bg-destructive", "bg-warning", "bg-success", "bg-success"][passwordStrength] + + const validate = () => { + const err: typeof fieldErr = {} + if (!password) err.password = "كلمة المرور مطلوبة" + else if (password.length < 8) err.password = "8 أحرف على الأقل" + if (!confirmPass) err.confirmPass = "يرجى تأكيد كلمة المرور" + else if (password !== confirmPass) err.confirmPass = "كلمتا المرور غير متطابقتين" + setFieldErr(err) + return !Object.keys(err).length + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!validate()) return + setLoading(true) + const { error } = await updatePassword(password) + setLoading(false) + + if (error) { + toast.error("تعذّر تحديث كلمة المرور — يرجى المحاولة مرة أخرى") + return + } + + setDone(true) + setTimeout(() => router.push("/login?reset=1"), 2800) + } + + // ── Success state ────────────────────────────────────────────────────────── + if (done) { + return ( +
+ +
+ +
+

تم تحديث كلمة المرور

+

+ يتم توجيهك لتسجيل الدخول... +

+
+ +
+ ) + } + + // ── Loading/invalid session ──────────────────────────────────────────────── + if (!ready) { + return ( +
+
+
+

التحقق من الجلسة...

+
+
+ ) + } + + // ── Reset form ───────────────────────────────────────────────────────────── + return ( +
+ + {/* Logo */} +
+ +
+ +
+ تبيان الطبي + +
+ +
+
+
+ +
+

تعيين كلمة مرور جديدة

+

اختر كلمة مرور قوية لحماية حسابك

+
+ +
+ + {/* New password */} +
+ +
+ + { setPassword(e.target.value); setFieldErr(p => ({ ...p, password: undefined })) }} + className={`w-full pr-10 pl-10 py-3 rounded-2xl text-sm bg-input border transition-colors outline-none + focus:border-ring focus:ring-2 focus:ring-ring/20 placeholder:text-muted-foreground + ${fieldErr.password ? "border-destructive focus:border-destructive focus:ring-destructive/20" : "border-border/60"}`} + /> + +
+ {/* Strength */} + {password && ( + +
+ {[1, 2, 3, 4].map(n => ( +
+ ))} +
+ {strengthLabel &&

القوة: {strengthLabel}

} + + )} + + {fieldErr.password && ( + {fieldErr.password} + )} + +
+ + {/* Confirm password */} +
+ +
+ + { setConfirmPass(e.target.value); setFieldErr(p => ({ ...p, confirmPass: undefined })) }} + className={`w-full pr-10 pl-10 py-3 rounded-2xl text-sm bg-input border transition-colors outline-none + focus:border-ring focus:ring-2 focus:ring-ring/20 placeholder:text-muted-foreground + ${fieldErr.confirmPass ? "border-destructive focus:border-destructive focus:ring-destructive/20" : "border-border/60"}`} + /> + +
+ + {fieldErr.confirmPass && ( + {fieldErr.confirmPass} + )} + +
+ + + {loading ? ( + <> + + جارٍ الحفظ... + + ) : ( + <> + + حفظ كلمة المرور الجديدة + + )} + + +
+ +
+ ) +} diff --git a/frontend/app/api/auth/[...nextauth]/route.ts b/frontend/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index 447e41d0ca1a4a50d87781abaad04751a90b2c43..0000000000000000000000000000000000000000 --- a/frontend/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import NextAuth from "next-auth" -import { authOptions } from "@/lib/auth" - -export const dynamic = "force-dynamic" - -const handler = NextAuth(authOptions) -export { handler as GET, handler as POST } diff --git a/frontend/app/auth/confirm/route.ts b/frontend/app/auth/confirm/route.ts index 875e9ae8bc1c6d50c31dc7c63c8c3f7ccf2fef1d..5d1d625802f37ab348151051bd9eff154e6ec188 100644 --- a/frontend/app/auth/confirm/route.ts +++ b/frontend/app/auth/confirm/route.ts @@ -3,10 +3,18 @@ import { type NextRequest, NextResponse } from "next/server" import { createServerClient } from "@supabase/ssr" import { cookies } from "next/headers" +/** + * Handles Supabase email link callbacks: + * - signup confirmation → /login?verified=1 + * - password recovery → /reset-password + * - magic link → / + * - error → /login?error=invalid_link + */ export async function GET(request: NextRequest) { const { searchParams, origin } = new URL(request.url) const token_hash = searchParams.get("token_hash") const type = searchParams.get("type") as EmailOtpType | null + const next = searchParams.get("next") ?? "/" if (token_hash && type) { const cookieStore = await cookies() @@ -15,7 +23,7 @@ export async function GET(request: NextRequest) { process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { - getAll: () => cookieStore.getAll(), + getAll: () => cookieStore.getAll(), setAll: (list) => list.forEach(({ name, value, options }) => cookieStore.set(name, value, options) ), @@ -24,8 +32,20 @@ export async function GET(request: NextRequest) { ) const { error } = await supabase.auth.verifyOtp({ type, token_hash }) - if (!error) return NextResponse.redirect(`${origin}/`) + + if (!error) { + // Password recovery: session is now set, let user choose new password + if (type === "recovery") { + return NextResponse.redirect(`${origin}/reset-password`) + } + // Email signup confirmation + if (type === "signup" || type === "email") { + return NextResponse.redirect(`${origin}/login?verified=1`) + } + // Magic link or other types + return NextResponse.redirect(`${origin}${next}`) + } } - return NextResponse.redirect(`${origin}/?error=confirmation_failed`) + return NextResponse.redirect(`${origin}/login?error=invalid_link`) } diff --git a/frontend/app/compare/page.tsx b/frontend/app/compare/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..520f01bb3d0bd1199ffbd7cf7e45d23469f5a56b --- /dev/null +++ b/frontend/app/compare/page.tsx @@ -0,0 +1,53 @@ +"use client" + +export default function ComparePage() { + return ( +
+ {/* Header */} +
+
+
+
+ الحالي +
+ vs +
+
+ المقترح ✨ +
+
+ مقارنة التحسينات +
+ + {/* Split view */} +
+ {/* Current */} +
+
+ الحالي — localhost:3000 +
+