Spaces:
Sleeping
fix: repair health chat and rewrite the diet planner for Pakistani home food
Browse filesChat and diet only. Nothing else on this Space is touched.
Health chat was returning a generic apology for every message. The single
free model it called was failing with no retry and no alternative, so any
rate limit or model retirement took the feature down completely.
- OpenRouter client now sends a fallback chain (glm-4.5-air -> llama-3.3-70b
-> openrouter/free, capped at 3 because OpenRouter rejects longer chains
with a 400), retries 429/5xx with backoff honouring Retry-After, fails fast
on a bad key, handles HTTP 200 error bodies, and reads `reasoning` when
`content` comes back empty.
- Reasoning models on the free tier spend most of a small token budget
thinking and truncate the answer, so the diet request now asks for low,
excluded reasoning with real headroom, salvages complete days out of
truncated JSON, and tops a 5- or 6-day result up to 7 instead of discarding
it.
- Chat answers even when Supabase is unreachable; it just answers without
saved history rather than failing the request.
- Outages now return ok:false with a reason the UI can show, instead of a
silent apology that looks like a real reply. ChatResponse gains `ok` and
`error` for that.
Diet plans were suggesting oatmeal, quinoa and "protein salad bowl" — food
most households here do not recognise or buy. The prompt and the offline
fallback are rewritten around everyday home food (anda, roti, daal, chawal,
sabzi, dahi, kela) in plain language, in both English and Urdu. Generated
plans are rejected and replaced when they lean on unfamiliar ingredients,
break a stated restriction (egg, dairy, gluten, nuts — these may be
allergies), or contain food unsafe for the stated condition: a live run put
halwa puri in a diabetic plan.
Adapted for this Space: the routers here keep no rate limiting, since this
deployment has no slowapi, and config.py gains only the fallback-model
setting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- app/config.py +9 -0
- app/models/schemas.py +4 -0
- app/routers/chat.py +75 -19
- app/routers/diet.py +0 -1
- app/services/chatbot_service.py +108 -49
- app/services/diet_service.py +763 -164
- app/services/openrouter_client.py +245 -39
|
@@ -21,6 +21,15 @@ class Settings(BaseSettings):
|
|
| 21 |
# OpenRouter
|
| 22 |
openrouter_api_key: str = ""
|
| 23 |
openrouter_model: str = "z-ai/glm-4.5-air:free"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
openrouter_site_url: str = "http://localhost:5173"
|
| 25 |
openrouter_app_name: str = "XRayVision AI"
|
| 26 |
openrouter_timeout_seconds: float = 60.0
|
|
|
|
| 21 |
# OpenRouter
|
| 22 |
openrouter_api_key: str = ""
|
| 23 |
openrouter_model: str = "z-ai/glm-4.5-air:free"
|
| 24 |
+
# Free model IDs get rate limited and retired without warning, which
|
| 25 |
+
# used to take the chatbot and diet planner offline. These are tried
|
| 26 |
+
# in order when the primary model fails. `openrouter/free` is
|
| 27 |
+
# OpenRouter's auto-router across whatever free models are healthy.
|
| 28 |
+
# Max 3 entries in total: OpenRouter rejects a longer chain with 400.
|
| 29 |
+
openrouter_fallback_models: str = (
|
| 30 |
+
"meta-llama/llama-3.3-70b-instruct:free,"
|
| 31 |
+
"openrouter/free"
|
| 32 |
+
)
|
| 33 |
openrouter_site_url: str = "http://localhost:5173"
|
| 34 |
openrouter_app_name: str = "XRayVision AI"
|
| 35 |
openrouter_timeout_seconds: float = 60.0
|
|
@@ -125,6 +125,10 @@ class ChatResponse(BaseModel):
|
|
| 125 |
reply: str
|
| 126 |
doctor_type: Optional[str] = None
|
| 127 |
home_remedies: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
|
| 130 |
class ChatSession(BaseModel):
|
|
|
|
| 125 |
reply: str
|
| 126 |
doctor_type: Optional[str] = None
|
| 127 |
home_remedies: list[str] = []
|
| 128 |
+
# False when the AI service could not be reached; `error` carries the reason
|
| 129 |
+
# so the UI can show it instead of silently pretending the bot replied.
|
| 130 |
+
ok: bool = True
|
| 131 |
+
error: Optional[str] = None
|
| 132 |
|
| 133 |
|
| 134 |
class ChatSession(BaseModel):
|
|
@@ -1,6 +1,8 @@
|
|
| 1 |
"""Health chatbot endpoints — FYP requirement."""
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
| 4 |
from fastapi import APIRouter, Depends, HTTPException, status
|
| 5 |
from app.models.schemas import ChatRequest, ChatResponse, ChatSession
|
| 6 |
from app.services.auth_service import get_current_user_id
|
|
@@ -13,6 +15,8 @@ from app.utils.supabase_client import (
|
|
| 13 |
user_owns_chat_session,
|
| 14 |
)
|
| 15 |
|
|
|
|
|
|
|
| 16 |
router = APIRouter(prefix="/chat", tags=["chat"])
|
| 17 |
|
| 18 |
|
|
@@ -21,43 +25,92 @@ async def send_message(
|
|
| 21 |
req: ChatRequest,
|
| 22 |
user_id: str = Depends(get_current_user_id),
|
| 23 |
):
|
| 24 |
-
"""Send a message to the health chatbot.
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
session_id = req.session_id
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
-
# Get AI response
|
| 40 |
result = chat_with_health_bot(
|
| 41 |
-
message=
|
| 42 |
conversation_history=history,
|
| 43 |
language=req.language,
|
| 44 |
)
|
| 45 |
|
| 46 |
-
#
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
return ChatResponse(
|
| 50 |
session_id=session_id,
|
| 51 |
reply=result["reply"],
|
| 52 |
doctor_type=result.get("doctor_type"),
|
| 53 |
home_remedies=result.get("home_remedies", []),
|
|
|
|
|
|
|
| 54 |
)
|
| 55 |
|
| 56 |
|
| 57 |
@router.get("/sessions", response_model=list[ChatSession])
|
| 58 |
async def list_sessions(user_id: str = Depends(get_current_user_id)):
|
| 59 |
"""List all chat sessions for the user."""
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
return [
|
| 62 |
ChatSession(
|
| 63 |
id=s["id"],
|
|
@@ -74,5 +127,8 @@ async def get_messages(
|
|
| 74 |
user_id: str = Depends(get_current_user_id),
|
| 75 |
):
|
| 76 |
"""Get all messages in a chat session."""
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Health chatbot endpoints — FYP requirement."""
|
| 2 |
|
| 3 |
+
import logging
|
| 4 |
+
import uuid
|
| 5 |
+
|
| 6 |
from fastapi import APIRouter, Depends, HTTPException, status
|
| 7 |
from app.models.schemas import ChatRequest, ChatResponse, ChatSession
|
| 8 |
from app.services.auth_service import get_current_user_id
|
|
|
|
| 15 |
user_owns_chat_session,
|
| 16 |
)
|
| 17 |
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
router = APIRouter(prefix="/chat", tags=["chat"])
|
| 21 |
|
| 22 |
|
|
|
|
| 25 |
req: ChatRequest,
|
| 26 |
user_id: str = Depends(get_current_user_id),
|
| 27 |
):
|
| 28 |
+
"""Send a message to the health chatbot.
|
| 29 |
+
|
| 30 |
+
Chat history is a convenience, not the feature. If Supabase is unreachable
|
| 31 |
+
or the chat tables are missing, the bot still answers — it just answers
|
| 32 |
+
without saved history rather than failing the whole request.
|
| 33 |
+
"""
|
| 34 |
+
message = (req.message or "").strip()
|
| 35 |
+
if not message:
|
| 36 |
+
raise HTTPException(
|
| 37 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 38 |
+
detail="Message cannot be empty.",
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
session_id = req.session_id
|
| 42 |
+
persisted = True
|
| 43 |
+
|
| 44 |
+
if session_id:
|
| 45 |
+
try:
|
| 46 |
+
if not user_owns_chat_session(session_id, user_id):
|
| 47 |
+
raise HTTPException(
|
| 48 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 49 |
+
detail="Chat session not found.",
|
| 50 |
+
)
|
| 51 |
+
except HTTPException:
|
| 52 |
+
raise
|
| 53 |
+
except Exception as exc: # noqa: BLE001
|
| 54 |
+
logger.warning("Could not verify chat session ownership: %s", exc)
|
| 55 |
+
persisted = False
|
| 56 |
+
else:
|
| 57 |
+
try:
|
| 58 |
+
session = create_chat_session(user_id, title=message[:50])
|
| 59 |
+
session_id = session.get("id")
|
| 60 |
+
except Exception as exc: # noqa: BLE001
|
| 61 |
+
logger.warning("Could not create chat session: %s", exc)
|
| 62 |
+
session_id = None
|
| 63 |
|
| 64 |
+
if not session_id:
|
| 65 |
+
# Keep the conversation usable for this page load even with no DB.
|
| 66 |
+
session_id = str(uuid.uuid4())
|
| 67 |
+
persisted = False
|
| 68 |
|
| 69 |
+
history: list[dict] = []
|
| 70 |
+
if persisted:
|
| 71 |
+
try:
|
| 72 |
+
history = get_chat_messages(session_id, user_id)
|
| 73 |
+
except Exception as exc: # noqa: BLE001
|
| 74 |
+
logger.warning("Could not load chat history: %s", exc)
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
insert_chat_message(session_id, "user", message)
|
| 78 |
+
except Exception as exc: # noqa: BLE001
|
| 79 |
+
logger.warning("Could not save user message: %s", exc)
|
| 80 |
+
persisted = False
|
| 81 |
|
|
|
|
| 82 |
result = chat_with_health_bot(
|
| 83 |
+
message=message,
|
| 84 |
conversation_history=history,
|
| 85 |
language=req.language,
|
| 86 |
)
|
| 87 |
|
| 88 |
+
# Only store real answers — don't pollute history with service outages.
|
| 89 |
+
if persisted and result.get("ok"):
|
| 90 |
+
try:
|
| 91 |
+
insert_chat_message(session_id, "assistant", result["reply"])
|
| 92 |
+
except Exception as exc: # noqa: BLE001
|
| 93 |
+
logger.warning("Could not save assistant message: %s", exc)
|
| 94 |
|
| 95 |
return ChatResponse(
|
| 96 |
session_id=session_id,
|
| 97 |
reply=result["reply"],
|
| 98 |
doctor_type=result.get("doctor_type"),
|
| 99 |
home_remedies=result.get("home_remedies", []),
|
| 100 |
+
ok=bool(result.get("ok", True)),
|
| 101 |
+
error=result.get("error"),
|
| 102 |
)
|
| 103 |
|
| 104 |
|
| 105 |
@router.get("/sessions", response_model=list[ChatSession])
|
| 106 |
async def list_sessions(user_id: str = Depends(get_current_user_id)):
|
| 107 |
"""List all chat sessions for the user."""
|
| 108 |
+
try:
|
| 109 |
+
sessions = get_chat_sessions(user_id)
|
| 110 |
+
except Exception as exc: # noqa: BLE001
|
| 111 |
+
logger.warning("Could not list chat sessions: %s", exc)
|
| 112 |
+
return []
|
| 113 |
+
|
| 114 |
return [
|
| 115 |
ChatSession(
|
| 116 |
id=s["id"],
|
|
|
|
| 127 |
user_id: str = Depends(get_current_user_id),
|
| 128 |
):
|
| 129 |
"""Get all messages in a chat session."""
|
| 130 |
+
try:
|
| 131 |
+
return get_chat_messages(session_id, user_id)
|
| 132 |
+
except Exception as exc: # noqa: BLE001
|
| 133 |
+
logger.warning("Could not load chat messages: %s", exc)
|
| 134 |
+
return []
|
|
@@ -1,6 +1,5 @@
|
|
| 1 |
"""Diet plan generator endpoint — FYP requirement."""
|
| 2 |
|
| 3 |
-
from __future__ import annotations
|
| 4 |
from fastapi import APIRouter, Depends
|
| 5 |
from app.models.schemas import DietRequest, DietPlanResponse, DayPlan, MealItem
|
| 6 |
from app.services.auth_service import get_current_user_id
|
|
|
|
| 1 |
"""Diet plan generator endpoint — FYP requirement."""
|
| 2 |
|
|
|
|
| 3 |
from fastapi import APIRouter, Depends
|
| 4 |
from app.models.schemas import DietRequest, DietPlanResponse, DayPlan, MealItem
|
| 5 |
from app.services.auth_service import get_current_user_id
|
|
@@ -1,17 +1,23 @@
|
|
| 1 |
-
"""Health chatbot service powered by OpenRouter
|
| 2 |
|
| 3 |
FYP requirement: General doctor bot for health queries, symptom analysis,
|
| 4 |
home remedies, doctor type recommendation, and multilingual support.
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
|
|
|
| 8 |
import logging
|
| 9 |
-
|
|
|
|
| 10 |
|
| 11 |
logger = logging.getLogger(__name__)
|
| 12 |
|
| 13 |
|
| 14 |
-
SYSTEM_PROMPT_EN = """You are XRayVision AI Health Assistant — a knowledgeable, empathetic medical chatbot.
|
|
|
|
| 15 |
You help patients with:
|
| 16 |
1. Symptom analysis and possible conditions
|
| 17 |
2. Basic home remedies and first-aid guidance
|
|
@@ -24,14 +30,19 @@ IMPORTANT RULES:
|
|
| 24 |
- For serious symptoms, ALWAYS recommend visiting a hospital.
|
| 25 |
- Be empathetic and clear in your responses.
|
| 26 |
- If asked about medications, suggest consulting a pharmacist or doctor.
|
| 27 |
-
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
Respond in a structured, helpful manner.
|
| 31 |
-
If you suggest home remedies, list them as bullet points.
|
| 32 |
"""
|
| 33 |
|
| 34 |
-
SYSTEM_PROMPT_UR = """آپ XRayVision AI ہیلتھ اسسٹنٹ ہیں — ایک ذہین اور ہمدرد طبی چیٹ بوٹ۔
|
|
|
|
| 35 |
آپ مریضوں کی مدد کرتے ہیں:
|
| 36 |
1. علامات کا تجزیہ اور ممکنہ بیماریاں
|
| 37 |
2. بنیادی گھریلو علاج اور فرسٹ ایڈ
|
|
@@ -42,16 +53,49 @@ SYSTEM_PROMPT_UR = """آپ XRayVision AI ہیلتھ اسسٹنٹ ہیں — ای
|
|
| 42 |
اہم قواعد:
|
| 43 |
- ہمیشہ یاد دلائیں کہ آپ AI اسسٹنٹ ہیں، حقیقی ڈاکٹر نہیں۔
|
| 44 |
- سنگین علامات کے لیے ہمیشہ ہسپتال جانے کی سفارش کریں۔
|
| 45 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
"""
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
def chat_with_health_bot(
|
| 50 |
message: str,
|
| 51 |
conversation_history: list[dict] | None = None,
|
| 52 |
language: str = "en",
|
| 53 |
) -> dict:
|
| 54 |
-
"""Process a health query and return AI response.
|
| 55 |
|
| 56 |
Args:
|
| 57 |
message: The user's message.
|
|
@@ -59,47 +103,54 @@ def chat_with_health_bot(
|
|
| 59 |
language: "en" for English, "ur" for Urdu.
|
| 60 |
|
| 61 |
Returns:
|
| 62 |
-
Dict with reply, doctor_type, and
|
|
|
|
| 63 |
"""
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
messages = [system_prompt]
|
| 68 |
-
|
| 69 |
-
if conversation_history:
|
| 70 |
-
for msg in conversation_history[-10:]: # Last 10 messages for context
|
| 71 |
-
role = "User" if msg.get("role") == "user" else "Assistant"
|
| 72 |
-
messages.append(f"{role}: {msg.get('content', '')}")
|
| 73 |
|
| 74 |
-
|
|
|
|
|
|
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
| 79 |
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
-
|
| 83 |
-
DOCTOR_TYPE: <specialist type or "none">
|
| 84 |
-
HOME_REMEDIES: <comma-separated list of remedies or "none">
|
| 85 |
-
"""
|
| 86 |
|
| 87 |
try:
|
| 88 |
-
response_text =
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
logger.error(f"Chatbot error: {e}")
|
| 92 |
return {
|
| 93 |
-
"reply":
|
| 94 |
-
"I apologize, but I'm currently unable to process your request. "
|
| 95 |
-
"Please try again later or consult a medical professional directly."
|
| 96 |
-
if language == "en" else
|
| 97 |
-
"معذرت، میں ابھی آپ کی درخواست پر عمل نہیں کر سکتا۔ "
|
| 98 |
-
"براہ کرم بعد میں دوبارہ کوشش کریں یا براہ راست ڈاکٹر سے مشورہ کریں۔"
|
| 99 |
-
),
|
| 100 |
"doctor_type": None,
|
| 101 |
"home_remedies": [],
|
|
|
|
|
|
|
| 102 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
|
| 105 |
def _parse_chat_response(text: str) -> dict:
|
|
@@ -107,22 +158,30 @@ def _parse_chat_response(text: str) -> dict:
|
|
| 107 |
lines = text.strip().split("\n")
|
| 108 |
reply_lines = []
|
| 109 |
doctor_type = None
|
| 110 |
-
home_remedies = []
|
| 111 |
|
| 112 |
for line in lines:
|
| 113 |
-
stripped = line.strip()
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
| 120 |
home_remedies = [r.strip() for r in val.split(",") if r.strip()]
|
| 121 |
else:
|
| 122 |
reply_lines.append(line)
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
return {
|
| 125 |
-
"reply":
|
| 126 |
"doctor_type": doctor_type,
|
| 127 |
"home_remedies": home_remedies,
|
| 128 |
}
|
|
|
|
| 1 |
+
"""Health chatbot service powered by OpenRouter.
|
| 2 |
|
| 3 |
FYP requirement: General doctor bot for health queries, symptom analysis,
|
| 4 |
home remedies, doctor type recommendation, and multilingual support.
|
| 5 |
+
|
| 6 |
+
The bot answers in plain, everyday language for a Pakistani audience — no
|
| 7 |
+
clinical jargon the average user would not recognise.
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
+
|
| 12 |
import logging
|
| 13 |
+
|
| 14 |
+
from app.services.openrouter_client import OpenRouterError, complete_chat
|
| 15 |
|
| 16 |
logger = logging.getLogger(__name__)
|
| 17 |
|
| 18 |
|
| 19 |
+
SYSTEM_PROMPT_EN = """You are the XRayVision AI Health Assistant — a knowledgeable, empathetic medical chatbot for users in Pakistan.
|
| 20 |
+
|
| 21 |
You help patients with:
|
| 22 |
1. Symptom analysis and possible conditions
|
| 23 |
2. Basic home remedies and first-aid guidance
|
|
|
|
| 30 |
- For serious symptoms, ALWAYS recommend visiting a hospital.
|
| 31 |
- Be empathetic and clear in your responses.
|
| 32 |
- If asked about medications, suggest consulting a pharmacist or doctor.
|
| 33 |
+
- Keep responses short and simple: 2-4 short paragraphs at most.
|
| 34 |
+
|
| 35 |
+
LANGUAGE RULES:
|
| 36 |
+
- Write in simple, everyday English that someone with no medical background understands.
|
| 37 |
+
- Avoid medical jargon. If you must use a medical term, explain it in brackets right after.
|
| 38 |
+
- When you mention food, use everyday Pakistani household items — egg, roti, bread, rice,
|
| 39 |
+
daal, yogurt (dahi), banana, milk, chicken soup, tea — not foreign or unfamiliar ingredients.
|
| 40 |
|
| 41 |
+
Respond in a structured, helpful manner.
|
|
|
|
| 42 |
"""
|
| 43 |
|
| 44 |
+
SYSTEM_PROMPT_UR = """آپ XRayVision AI ہیلتھ اسسٹنٹ ہیں — پاکستانی صارفین کے لیے ایک ذہین اور ہمدرد طبی چیٹ بوٹ۔
|
| 45 |
+
|
| 46 |
آپ مریضوں کی مدد کرتے ہیں:
|
| 47 |
1. علامات کا تجزیہ اور ممکنہ بیماریاں
|
| 48 |
2. بنیادی گھریلو علاج اور فرسٹ ایڈ
|
|
|
|
| 53 |
اہم قواعد:
|
| 54 |
- ہمیشہ یاد دلائیں کہ آپ AI اسسٹنٹ ہیں، حقیقی ڈاکٹر نہیں۔
|
| 55 |
- سنگین علامات کے لیے ہمیشہ ہسپتال جانے کی سفارش کریں۔
|
| 56 |
+
- جواب مختصر رکھیں — زیادہ سے زیادہ 2 سے 4 چھوٹے پیراگراف۔
|
| 57 |
+
|
| 58 |
+
زبان کے قواعد:
|
| 59 |
+
- ہمیشہ صاف اور آسان اردو میں جواب دیں، ایسی اردو جو ہر عام آدمی سمجھ سکے۔
|
| 60 |
+
- مشکل طبی اصطلاحات استعمال نہ کریں۔ اگر ضروری ہو تو بریکٹ میں آسان لفظوں میں سمجھائیں۔
|
| 61 |
+
- کھانے کی بات کریں تو عام پاکستانی گھریلو چیزیں بتائیں — انڈا، روٹی، ڈبل روٹی، چاول، دال،
|
| 62 |
+
دہی، کیلا، دودھ، یخنی، چائے — باہر کی یا انجان چیزیں نہ بتائیں۔
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
# The markers stay in English so parsing works in both languages; only the
|
| 66 |
+
# values after the colon are translated.
|
| 67 |
+
OUTPUT_FORMAT_EN = """
|
| 68 |
+
At the very end of your reply, on their own separate lines, add exactly:
|
| 69 |
+
DOCTOR_TYPE: <the specialist to see, or the word none>
|
| 70 |
+
HOME_REMEDIES: <simple remedies separated by commas, or the word none>
|
| 71 |
"""
|
| 72 |
|
| 73 |
+
OUTPUT_FORMAT_UR = """
|
| 74 |
+
اپنے جواب کے بالکل آخر میں، الگ الگ سطروں میں، بالکل یہ لکھیں
|
| 75 |
+
(لیبل انگریزی میں رکھیں، تفصیل اردو میں لکھیں):
|
| 76 |
+
DOCTOR_TYPE: <کس ماہر ڈاکٹر سے ملنا ہے، یا لفظ none>
|
| 77 |
+
HOME_REMEDIES: <آسان گھریلو علاج، کوما سے الگ کر کے، یا لفظ none>
|
| 78 |
+
"""
|
| 79 |
+
|
| 80 |
+
FALLBACK_REPLY_EN = (
|
| 81 |
+
"Sorry — I could not reach the AI service just now. "
|
| 82 |
+
"Please try again in a moment. If your symptoms are severe or getting worse, "
|
| 83 |
+
"do not wait for this chat: contact a doctor or go to the nearest hospital."
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
FALLBACK_REPLY_UR = (
|
| 87 |
+
"معذرت — میں ابھی AI سروس سے رابطہ نہیں کر سکا۔ "
|
| 88 |
+
"براہ کرم تھوڑی دیر بعد دوبارہ کوشش کریں۔ اگر آپ کی تکلیف شدید ہے یا بڑھ رہی ہے "
|
| 89 |
+
"تو اس چیٹ کا انتظار نہ کریں — فوراً ڈاکٹر سے رابطہ کریں یا قریبی ہسپتال جائیں۔"
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
|
| 93 |
def chat_with_health_bot(
|
| 94 |
message: str,
|
| 95 |
conversation_history: list[dict] | None = None,
|
| 96 |
language: str = "en",
|
| 97 |
) -> dict:
|
| 98 |
+
"""Process a health query and return an AI response.
|
| 99 |
|
| 100 |
Args:
|
| 101 |
message: The user's message.
|
|
|
|
| 103 |
language: "en" for English, "ur" for Urdu.
|
| 104 |
|
| 105 |
Returns:
|
| 106 |
+
Dict with reply, doctor_type, home_remedies, and an `ok` flag that is
|
| 107 |
+
False when the AI service could not be reached.
|
| 108 |
"""
|
| 109 |
+
urdu = language == "ur"
|
| 110 |
+
system_prompt = SYSTEM_PROMPT_UR if urdu else SYSTEM_PROMPT_EN
|
| 111 |
+
output_format = OUTPUT_FORMAT_UR if urdu else OUTPUT_FORMAT_EN
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
+
# Real chat roles instead of one flattened prompt — this is what the model
|
| 114 |
+
# is trained on, and it keeps the system rules from being ignored.
|
| 115 |
+
messages: list[dict] = [{"role": "system", "content": system_prompt + output_format}]
|
| 116 |
|
| 117 |
+
for msg in (conversation_history or [])[-10:]:
|
| 118 |
+
role = "user" if msg.get("role") == "user" else "assistant"
|
| 119 |
+
content = (msg.get("content") or "").strip()
|
| 120 |
+
if content:
|
| 121 |
+
messages.append({"role": role, "content": content})
|
| 122 |
|
| 123 |
+
if urdu:
|
| 124 |
+
# Repeated next to the user's turn because a long history can otherwise
|
| 125 |
+
# pull the model back into English.
|
| 126 |
+
message = f"{message}\n\n(اہم: پورا جواب صرف اردو میں دیں۔)"
|
| 127 |
|
| 128 |
+
messages.append({"role": "user", "content": message})
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
try:
|
| 131 |
+
response_text = complete_chat(messages, temperature=0.35, max_tokens=1200)
|
| 132 |
+
except OpenRouterError as exc:
|
| 133 |
+
logger.error("Chatbot unavailable: %s", exc)
|
|
|
|
| 134 |
return {
|
| 135 |
+
"reply": FALLBACK_REPLY_UR if urdu else FALLBACK_REPLY_EN,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
"doctor_type": None,
|
| 137 |
"home_remedies": [],
|
| 138 |
+
"ok": False,
|
| 139 |
+
"error": exc.user_message,
|
| 140 |
}
|
| 141 |
+
except Exception as exc: # noqa: BLE001 - never let the chat route 500
|
| 142 |
+
logger.exception("Unexpected chatbot error: %s", exc)
|
| 143 |
+
return {
|
| 144 |
+
"reply": FALLBACK_REPLY_UR if urdu else FALLBACK_REPLY_EN,
|
| 145 |
+
"doctor_type": None,
|
| 146 |
+
"home_remedies": [],
|
| 147 |
+
"ok": False,
|
| 148 |
+
"error": "The AI service is unavailable right now.",
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
parsed = _parse_chat_response(response_text)
|
| 152 |
+
parsed["ok"] = True
|
| 153 |
+
return parsed
|
| 154 |
|
| 155 |
|
| 156 |
def _parse_chat_response(text: str) -> dict:
|
|
|
|
| 158 |
lines = text.strip().split("\n")
|
| 159 |
reply_lines = []
|
| 160 |
doctor_type = None
|
| 161 |
+
home_remedies: list[str] = []
|
| 162 |
|
| 163 |
for line in lines:
|
| 164 |
+
stripped = line.strip().lstrip("*# ").strip()
|
| 165 |
+
upper = stripped.upper()
|
| 166 |
+
|
| 167 |
+
if upper.startswith("DOCTOR_TYPE:"):
|
| 168 |
+
val = stripped.split(":", 1)[1].strip().strip("*_ ")
|
| 169 |
+
doctor_type = val if val.lower() not in ("none", "") else None
|
| 170 |
+
elif upper.startswith("HOME_REMEDIES:"):
|
| 171 |
+
val = stripped.split(":", 1)[1].strip().strip("*_ ")
|
| 172 |
+
if val.lower() not in ("none", ""):
|
| 173 |
home_remedies = [r.strip() for r in val.split(",") if r.strip()]
|
| 174 |
else:
|
| 175 |
reply_lines.append(line)
|
| 176 |
|
| 177 |
+
reply = "\n".join(reply_lines).strip()
|
| 178 |
+
|
| 179 |
+
# If the model emitted nothing but the markers, still show something.
|
| 180 |
+
if not reply:
|
| 181 |
+
reply = text.strip()
|
| 182 |
+
|
| 183 |
return {
|
| 184 |
+
"reply": reply,
|
| 185 |
"doctor_type": doctor_type,
|
| 186 |
"home_remedies": home_remedies,
|
| 187 |
}
|
|
@@ -1,7 +1,11 @@
|
|
| 1 |
"""Personalized diet plan generator powered by OpenRouter.
|
| 2 |
|
| 3 |
-
The
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
|
@@ -10,13 +14,23 @@ import json
|
|
| 10 |
import logging
|
| 11 |
import re
|
| 12 |
|
| 13 |
-
from app.services.openrouter_client import complete_text
|
| 14 |
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
-
HYPERTENSION_TERMS = ("hypertension", "high blood pressure", "blood pressure", "bp")
|
| 18 |
-
DIABETES_TERMS = ("diabetes", "diabetic", "blood sugar", "glucose")
|
| 19 |
-
KIDNEY_TERMS = ("kidney", "renal", "ckd", "dialysis")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
def generate_diet_plan(
|
|
@@ -26,60 +40,97 @@ def generate_diet_plan(
|
|
| 26 |
goals: str = "general health",
|
| 27 |
language: str = "en",
|
| 28 |
) -> dict:
|
| 29 |
-
"""Generate a personalized
|
| 30 |
restrictions = restrictions or []
|
| 31 |
condition_text = condition or "general wellness"
|
| 32 |
restrictions_text = ", ".join(restrictions) if restrictions else "none"
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
prompt = f"""You are a nutrition assistant creating
|
|
|
|
| 36 |
|
| 37 |
PATIENT CONTEXT:
|
| 38 |
- Medical Condition: {condition_text}
|
| 39 |
- Dietary Preference: {dietary_preferences}
|
| 40 |
-
-
|
| 41 |
-
- Health
|
| 42 |
|
| 43 |
{lang_instruction}
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
MEDICAL AND SAFETY RULES:
|
| 46 |
{_condition_rules(condition_text, goals)}
|
| 47 |
|
| 48 |
-
|
| 49 |
{_restriction_rules(restrictions)}
|
| 50 |
|
| 51 |
QUALITY RULES:
|
| 52 |
- Include exactly 7 days, Day 1 through Day 7.
|
| 53 |
-
- Use realistic
|
| 54 |
- Calorie values must be plausible estimates per meal.
|
| 55 |
-
-
|
| 56 |
-
-
|
| 57 |
-
- Do not claim the plan diagnoses, treats, cures, or replaces clinician care.
|
| 58 |
|
| 59 |
-
Respond ONLY in this JSON format, with no markdown
|
| 60 |
{{
|
| 61 |
-
"title": "<
|
| 62 |
-
"summary": "<2-3
|
| 63 |
"plan": [
|
| 64 |
{{
|
| 65 |
"day": "Day 1",
|
| 66 |
-
"breakfast": {{"name": "<meal>", "description": "<
|
| 67 |
-
"lunch": {{"name": "<meal>", "description": "<
|
| 68 |
-
"dinner": {{"name": "<meal>", "description": "<
|
| 69 |
-
"snacks": [{{"name": "<snack>", "description": "<
|
| 70 |
}}
|
| 71 |
],
|
| 72 |
-
"tips": ["<
|
| 73 |
}}
|
| 74 |
"""
|
| 75 |
|
| 76 |
try:
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
parsed = _parse_diet_response(response_text)
|
| 79 |
-
return _normalize_diet_plan(
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
|
| 85 |
def _has_any(text: str, terms: tuple[str, ...]) -> bool:
|
|
@@ -90,28 +141,35 @@ def _has_any(text: str, terms: tuple[str, ...]) -> bool:
|
|
| 90 |
def _condition_rules(condition: str, goals: str) -> str:
|
| 91 |
context = f"{condition} {goals}".lower()
|
| 92 |
rules = [
|
| 93 |
-
"Use
|
| 94 |
-
"
|
| 95 |
-
"Do not include
|
| 96 |
-
"Include calorie estimates, but
|
| 97 |
]
|
| 98 |
|
| 99 |
if _has_any(context, HYPERTENSION_TERMS):
|
| 100 |
rules.extend([
|
| 101 |
-
"For blood pressure,
|
| 102 |
-
"
|
| 103 |
-
"
|
| 104 |
-
"
|
|
|
|
|
|
|
| 105 |
])
|
| 106 |
if _has_any(context, DIABETES_TERMS):
|
| 107 |
rules.extend([
|
| 108 |
-
"For diabetes,
|
| 109 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
])
|
| 111 |
if _has_any(context, KIDNEY_TERMS):
|
| 112 |
rules.extend([
|
| 113 |
-
"For kidney
|
| 114 |
-
"
|
| 115 |
])
|
| 116 |
|
| 117 |
return "\n".join(f"- {rule}" for rule in rules)
|
|
@@ -121,97 +179,421 @@ def _restriction_rules(restrictions: list[str]) -> str:
|
|
| 121 |
joined = " ".join(restrictions).lower()
|
| 122 |
rules: list[str] = []
|
| 123 |
|
| 124 |
-
if "dairy" in joined or "lactose" in joined:
|
| 125 |
-
rules.append(
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
if "
|
| 129 |
-
rules.append(
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
|
| 136 |
def _parse_diet_response(text: str) -> dict:
|
| 137 |
-
"""Parse the model response into structured diet plan."""
|
| 138 |
cleaned = text.strip()
|
|
|
|
|
|
|
| 139 |
if cleaned.startswith("```"):
|
| 140 |
lines = cleaned.split("\n")
|
| 141 |
cleaned = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip()
|
| 142 |
|
|
|
|
| 143 |
try:
|
| 144 |
parsed = json.loads(cleaned)
|
| 145 |
except json.JSONDecodeError:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
logger.warning("Failed to parse diet plan JSON")
|
| 147 |
return {}
|
| 148 |
|
|
|
|
|
|
|
|
|
|
| 149 |
return {
|
| 150 |
-
"title": parsed.get("title", "Your
|
| 151 |
"summary": parsed.get("summary", ""),
|
| 152 |
"plan": parsed.get("plan", []),
|
| 153 |
"tips": parsed.get("tips", []),
|
| 154 |
}
|
| 155 |
|
| 156 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
def _normalize_diet_plan(
|
| 158 |
plan: dict,
|
| 159 |
condition: str = "general wellness",
|
| 160 |
dietary_preferences: str = "balanced",
|
| 161 |
restrictions: list[str] | None = None,
|
| 162 |
goals: str = "general health",
|
|
|
|
| 163 |
) -> dict:
|
| 164 |
-
"""Keep generated plans complete and
|
| 165 |
restrictions = restrictions or []
|
| 166 |
days = plan.get("plan") if isinstance(plan.get("plan"), list) else []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
if len(days) != 7:
|
| 168 |
logger.warning("Diet plan had %s days; using validated fallback", len(days))
|
| 169 |
-
return _fallback_diet_plan(condition, dietary_preferences, restrictions, goals)
|
| 170 |
|
| 171 |
for idx, day in enumerate(days[:7], start=1):
|
| 172 |
if not isinstance(day, dict):
|
| 173 |
-
return _fallback_diet_plan(condition, dietary_preferences, restrictions, goals)
|
| 174 |
day["day"] = day.get("day") or f"Day {idx}"
|
| 175 |
for meal_name in ("breakfast", "lunch", "dinner"):
|
| 176 |
meal = day.get(meal_name)
|
| 177 |
if not isinstance(meal, dict) or not meal.get("name"):
|
| 178 |
logger.warning("Diet plan missing %s on day %s; using fallback", meal_name, idx)
|
| 179 |
-
return _fallback_diet_plan(condition, dietary_preferences, restrictions, goals)
|
| 180 |
if not isinstance(day.get("snacks"), list):
|
| 181 |
day["snacks"] = []
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
tips = [str(t) for t in plan.get("tips", []) if str(t).strip()]
|
| 184 |
context = f"{condition} {goals}"
|
|
|
|
| 185 |
|
| 186 |
if _has_any(context, HYPERTENSION_TERMS):
|
| 187 |
-
tips = _merge_tips(tips,
|
| 188 |
-
"Keep sodium under 2,300 mg/day; ask a clinician whether a 1,500 mg/day target is appropriate.",
|
| 189 |
-
"Use potassium-rich foods for blood pressure only if you do not have kidney disease and are not on potassium-raising medicines.",
|
| 190 |
-
"Choose no-salt-added foods and herbs/spices instead of salty sauces or packaged snacks.",
|
| 191 |
-
])
|
| 192 |
if _has_any(context, DIABETES_TERMS):
|
| 193 |
-
tips = _merge_tips(tips,
|
| 194 |
-
"Monitor blood glucose response and coordinate meal timing with prescribed diabetes medicines.",
|
| 195 |
-
"Pair carbohydrate foods with protein, fiber, and healthy fats to reduce glucose spikes.",
|
| 196 |
-
])
|
| 197 |
if _has_any(context, KIDNEY_TERMS):
|
| 198 |
-
tips = _merge_tips(tips,
|
| 199 |
-
"Kidney disease diets must be individualized for potassium, phosphorus, sodium, protein, and fluid limits.",
|
| 200 |
-
"Review this plan with a renal dietitian or clinician before following it.",
|
| 201 |
-
])
|
| 202 |
if condition and condition.lower() != "general wellness":
|
| 203 |
-
tips = _merge_tips(tips, [
|
| 204 |
-
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
|
| 207 |
return {
|
| 208 |
-
"title": plan.get("title") or
|
| 209 |
-
"summary": plan.get("summary") or
|
| 210 |
"plan": days[:7],
|
| 211 |
"tips": tips[:6],
|
| 212 |
}
|
| 213 |
|
| 214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
def _merge_tips(existing: list[str], required: list[str]) -> list[str]:
|
| 216 |
merged = list(existing)
|
| 217 |
comparable = " ".join(_tip_key(tip) for tip in existing)
|
|
@@ -222,109 +604,326 @@ def _merge_tips(existing: list[str], required: list[str]) -> list[str]:
|
|
| 222 |
|
| 223 |
|
| 224 |
def _tip_key(tip: str) -> str:
|
| 225 |
-
words = re.sub(r"[^a-z0-9]+", " ", tip.lower()).strip().split()
|
| 226 |
return " ".join(words[:5])
|
| 227 |
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
def _fallback_diet_plan(
|
| 230 |
condition: str = "general wellness",
|
| 231 |
dietary_preferences: str = "balanced",
|
| 232 |
restrictions: list[str] | None = None,
|
| 233 |
goals: str = "general health",
|
|
|
|
| 234 |
) -> dict:
|
| 235 |
-
"""
|
| 236 |
restrictions = restrictions or []
|
| 237 |
context = f"{condition} {goals}".lower()
|
| 238 |
restrictions_text = " ".join(restrictions).lower()
|
| 239 |
-
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
if _has_any(context, HYPERTENSION_TERMS):
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
"
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
"Use potassium-rich foods for blood pressure only if you do not have kidney disease and are not on potassium-raising medicines.",
|
| 286 |
-
"This educational plan should be reviewed by a qualified clinician or dietitian for your diagnosis, medicines, labs, and allergies.",
|
| 287 |
-
],
|
| 288 |
-
}
|
| 289 |
|
| 290 |
return {
|
| 291 |
-
"title":
|
| 292 |
-
"summary":
|
| 293 |
-
"plan":
|
| 294 |
-
|
| 295 |
-
"day": f"Day {i}",
|
| 296 |
-
"breakfast": {
|
| 297 |
-
"name": "Oatmeal with Fruit",
|
| 298 |
-
"description": "Steel-cut oats topped with banana, berries, and cinnamon",
|
| 299 |
-
"calories": 350,
|
| 300 |
-
"nutrients": "Fiber, vitamins, magnesium",
|
| 301 |
-
},
|
| 302 |
-
"lunch": {
|
| 303 |
-
"name": "Protein Salad Bowl",
|
| 304 |
-
"description": "Mixed greens with lean protein or beans, avocado, and olive oil dressing",
|
| 305 |
-
"calories": 450,
|
| 306 |
-
"nutrients": "Protein, healthy fats, fiber",
|
| 307 |
-
},
|
| 308 |
-
"dinner": {
|
| 309 |
-
"name": "Balanced Grain Plate",
|
| 310 |
-
"description": "Whole grains with vegetables and fish, poultry, tofu, or legumes",
|
| 311 |
-
"calories": 500,
|
| 312 |
-
"nutrients": "Protein, fiber, minerals",
|
| 313 |
-
},
|
| 314 |
-
"snacks": [
|
| 315 |
-
{
|
| 316 |
-
"name": "Fruit or Unsalted Seeds",
|
| 317 |
-
"description": "A simple whole-food snack",
|
| 318 |
-
"calories": 180,
|
| 319 |
-
"nutrients": "Fiber, minerals",
|
| 320 |
-
}
|
| 321 |
-
],
|
| 322 |
-
}
|
| 323 |
-
for i in range(1, 8)
|
| 324 |
-
],
|
| 325 |
-
"tips": [
|
| 326 |
-
"Stay hydrated with water unless your clinician has given fluid restrictions.",
|
| 327 |
-
"Eat meals at consistent times and include vegetables at most meals.",
|
| 328 |
-
"This educational plan should be reviewed by a qualified clinician or dietitian for your diagnosis, medicines, labs, and allergies.",
|
| 329 |
-
],
|
| 330 |
}
|
|
|
|
| 1 |
"""Personalized diet plan generator powered by OpenRouter.
|
| 2 |
|
| 3 |
+
The plans are written for ordinary households in Pakistan. That constraint is
|
| 4 |
+
the whole point of this module: an earlier version produced things like
|
| 5 |
+
"Protein Salad Bowl" and "Oat Berry Bowl", which most users here do not
|
| 6 |
+
recognise, cannot buy easily, and will not cook. Everything below — the prompt,
|
| 7 |
+
the validation, and the offline fallback — pushes the output towards food people
|
| 8 |
+
actually eat: anda, roti, daal, chawal, sabzi, dahi, kela.
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
|
|
|
| 14 |
import logging
|
| 15 |
import re
|
| 16 |
|
| 17 |
+
from app.services.openrouter_client import OpenRouterError, complete_text
|
| 18 |
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
| 21 |
+
HYPERTENSION_TERMS = ("hypertension", "high blood pressure", "blood pressure", "bp", "بلڈ پریشر")
|
| 22 |
+
DIABETES_TERMS = ("diabetes", "diabetic", "blood sugar", "glucose", "sugar", "shugar", "شوگر")
|
| 23 |
+
KIDNEY_TERMS = ("kidney", "renal", "ckd", "dialysis", "گردہ", "گردے")
|
| 24 |
+
|
| 25 |
+
# Foods the model reaches for by default that do not belong in a plan meant for
|
| 26 |
+
# a Pakistani kitchen. If several show up, the generated plan is rejected.
|
| 27 |
+
UNFAMILIAR_FOODS = (
|
| 28 |
+
"quinoa", "oat", "oatmeal", "granola", "kale", "avocado", "tofu",
|
| 29 |
+
"hummus", "chia", "smoothie bowl", "berry bowl", "salad bowl",
|
| 30 |
+
"cottage cheese", "salmon", "asparagus", "zucchini", "couscous",
|
| 31 |
+
"tortilla", "bagel", "pancake", "protein shake", "protein bar",
|
| 32 |
+
"miso", "sushi", "broccoli", "edamame",
|
| 33 |
+
)
|
| 34 |
|
| 35 |
|
| 36 |
def generate_diet_plan(
|
|
|
|
| 40 |
goals: str = "general health",
|
| 41 |
language: str = "en",
|
| 42 |
) -> dict:
|
| 43 |
+
"""Generate a simple, personalized 7-day Pakistani meal plan."""
|
| 44 |
restrictions = restrictions or []
|
| 45 |
condition_text = condition or "general wellness"
|
| 46 |
restrictions_text = ", ".join(restrictions) if restrictions else "none"
|
| 47 |
+
urdu = language == "ur"
|
| 48 |
+
|
| 49 |
+
lang_instruction = (
|
| 50 |
+
"Write every name, description, nutrient and tip in simple Urdu (اردو). "
|
| 51 |
+
"Use the Urdu words people actually say at home: انڈا، روٹی، دال، چاول، سبزی، دہی، کیلا۔"
|
| 52 |
+
if urdu
|
| 53 |
+
else "Write in simple English. Use the local name first and a short English "
|
| 54 |
+
"explanation in brackets where helpful, e.g. \"Daal Chawal (lentils with rice)\"."
|
| 55 |
+
)
|
| 56 |
|
| 57 |
+
prompt = f"""You are a nutrition assistant creating a simple, educational 7-day meal plan
|
| 58 |
+
for an ordinary household in Pakistan.
|
| 59 |
|
| 60 |
PATIENT CONTEXT:
|
| 61 |
- Medical Condition: {condition_text}
|
| 62 |
- Dietary Preference: {dietary_preferences}
|
| 63 |
+
- Things to avoid: {restrictions_text}
|
| 64 |
+
- Health Goal: {goals}
|
| 65 |
|
| 66 |
{lang_instruction}
|
| 67 |
|
| 68 |
+
THE MOST IMPORTANT RULE — KEEP IT SIMPLE AND LOCAL:
|
| 69 |
+
- Only use everyday Pakistani home food that is cheap and available in any local market.
|
| 70 |
+
- Good examples: anda (egg), roti, double roti (bread), chawal (rice), daal (lentils),
|
| 71 |
+
chana, sabzi (vegetable curry), aloo, palak, bhindi, dahi (yogurt), lassi, doodh (milk),
|
| 72 |
+
kela (banana), seb (apple), malta, chicken salan, yakhni, khichdi, paratha, halwa,
|
| 73 |
+
raita, kheera, gajar, tamatar, pyaz, chai.
|
| 74 |
+
- NEVER use foods most Pakistani households would not recognise or buy: no quinoa, no oats
|
| 75 |
+
or oatmeal, no granola, no kale, no avocado, no tofu, no hummus, no chia seeds, no
|
| 76 |
+
"protein salad bowl", no "berry bowl", no salmon, no broccoli, no couscous, no smoothies.
|
| 77 |
+
- Name each meal the way a person would say it at home, not like a restaurant menu.
|
| 78 |
+
- Descriptions must be one short, plain sentence a person with no education in nutrition
|
| 79 |
+
can follow. Say the portion in normal words: "2 roti", "aik pyali chawal", "aik anda".
|
| 80 |
+
- Do not use nutrition jargon. Write "gives energy" or "good for strength", not
|
| 81 |
+
"macronutrient profile" or "micronutrient density".
|
| 82 |
+
- Vary the 7 days so it is not the same food every day.
|
| 83 |
+
|
| 84 |
MEDICAL AND SAFETY RULES:
|
| 85 |
{_condition_rules(condition_text, goals)}
|
| 86 |
|
| 87 |
+
FOODS TO AVOID FOR THIS PERSON:
|
| 88 |
{_restriction_rules(restrictions)}
|
| 89 |
|
| 90 |
QUALITY RULES:
|
| 91 |
- Include exactly 7 days, Day 1 through Day 7.
|
| 92 |
+
- Use realistic home portions, not extreme dieting.
|
| 93 |
- Calorie values must be plausible estimates per meal.
|
| 94 |
+
- Give 3 to 5 short tips in plain language.
|
| 95 |
+
- Do not claim the plan diagnoses, treats, cures, or replaces a doctor.
|
|
|
|
| 96 |
|
| 97 |
+
Respond ONLY in this JSON format, with no markdown and no extra text:
|
| 98 |
{{
|
| 99 |
+
"title": "<short plan title>",
|
| 100 |
+
"summary": "<2-3 simple sentences about the plan>",
|
| 101 |
"plan": [
|
| 102 |
{{
|
| 103 |
"day": "Day 1",
|
| 104 |
+
"breakfast": {{"name": "<meal>", "description": "<one plain sentence>", "calories": <number>, "nutrients": "<simple benefit, e.g. protein and energy>"}},
|
| 105 |
+
"lunch": {{"name": "<meal>", "description": "<one plain sentence>", "calories": <number>, "nutrients": "<simple benefit>"}},
|
| 106 |
+
"dinner": {{"name": "<meal>", "description": "<one plain sentence>", "calories": <number>, "nutrients": "<simple benefit>"}},
|
| 107 |
+
"snacks": [{{"name": "<snack>", "description": "<one plain sentence>", "calories": <number>, "nutrients": "<simple benefit>"}}]
|
| 108 |
}}
|
| 109 |
],
|
| 110 |
+
"tips": ["<simple tip 1>", "<simple tip 2>", "<simple tip 3>"]
|
| 111 |
}}
|
| 112 |
"""
|
| 113 |
|
| 114 |
try:
|
| 115 |
+
# A 7-day plan is ~2,500 tokens of JSON on its own. The free tier
|
| 116 |
+
# routes to reasoning models that spend most of a small budget
|
| 117 |
+
# thinking, so give it real headroom and turn the thinking down.
|
| 118 |
+
response_text = complete_text(
|
| 119 |
+
prompt,
|
| 120 |
+
temperature=0.35,
|
| 121 |
+
max_tokens=16000,
|
| 122 |
+
reasoning={"effort": "low", "exclude": True},
|
| 123 |
+
)
|
| 124 |
parsed = _parse_diet_response(response_text)
|
| 125 |
+
return _normalize_diet_plan(
|
| 126 |
+
parsed, condition_text, dietary_preferences, restrictions, goals, language
|
| 127 |
+
)
|
| 128 |
+
except OpenRouterError as exc:
|
| 129 |
+
logger.error("Diet plan generation unavailable: %s", exc)
|
| 130 |
+
return _fallback_diet_plan(condition_text, dietary_preferences, restrictions, goals, language)
|
| 131 |
+
except Exception as exc: # noqa: BLE001
|
| 132 |
+
logger.exception("Diet plan generation failed: %s", exc)
|
| 133 |
+
return _fallback_diet_plan(condition_text, dietary_preferences, restrictions, goals, language)
|
| 134 |
|
| 135 |
|
| 136 |
def _has_any(text: str, terms: tuple[str, ...]) -> bool:
|
|
|
|
| 141 |
def _condition_rules(condition: str, goals: str) -> str:
|
| 142 |
context = f"{condition} {goals}".lower()
|
| 143 |
rules = [
|
| 144 |
+
"Use simple home-cooked food and normal portions.",
|
| 145 |
+
"Do not claim the plan treats or cures anything, or replaces a doctor.",
|
| 146 |
+
"Do not include any food the person said they must avoid.",
|
| 147 |
+
"Include calorie estimates, but never suggest starving or skipping meals.",
|
| 148 |
]
|
| 149 |
|
| 150 |
if _has_any(context, HYPERTENSION_TERMS):
|
| 151 |
rules.extend([
|
| 152 |
+
"For high blood pressure, keep salt low: say clearly to use less namak while cooking,"
|
| 153 |
+
" and to avoid achaar (pickle), papad, chips, packet noodles, and salty snacks.",
|
| 154 |
+
"Prefer fresh sabzi, daal, chana, fruit, and plain dahi.",
|
| 155 |
+
"Mention keeping salt under about one small teaspoon a day, and less if the doctor says so.",
|
| 156 |
+
"Only suggest potassium-rich foods with a caution: anyone with kidney trouble or on"
|
| 157 |
+
" blood pressure medicines should ask their doctor first.",
|
| 158 |
])
|
| 159 |
if _has_any(context, DIABETES_TERMS):
|
| 160 |
rules.extend([
|
| 161 |
+
"For sugar (diabetes), spread roti and rice through the day instead of one big meal,"
|
| 162 |
+
" and always pair them with daal, sabzi, anda, dahi or meat.",
|
| 163 |
+
"Say clearly to avoid soft drinks, packet juice, mithai, and lots of sugar in chai.",
|
| 164 |
+
"Prefer chapati over white bread, and a smaller portion of rice.",
|
| 165 |
+
"NEVER put these in a diabetic plan, not even once: halwa puri, puri, jalebi, gulab jamun,"
|
| 166 |
+
" mithai, sheer khurma, sweet lassi, rooh afza, sugary paratha, cake, biscuits, or any"
|
| 167 |
+
" deep-fried nashta. They are normal Pakistani foods but they are wrong for this person.",
|
| 168 |
])
|
| 169 |
if _has_any(context, KIDNEY_TERMS):
|
| 170 |
rules.extend([
|
| 171 |
+
"For kidney problems, do not push high-potassium or high-phosphorus food unless a doctor approves.",
|
| 172 |
+
"Say clearly that a kidney patient must get the plan checked by their own doctor or dietitian.",
|
| 173 |
])
|
| 174 |
|
| 175 |
return "\n".join(f"- {rule}" for rule in rules)
|
|
|
|
| 179 |
joined = " ".join(restrictions).lower()
|
| 180 |
rules: list[str] = []
|
| 181 |
|
| 182 |
+
if "dairy" in joined or "lactose" in joined or "milk" in joined:
|
| 183 |
+
rules.append(
|
| 184 |
+
"No dairy: no doodh, dahi, lassi, paneer, makhan, cream, or cheese."
|
| 185 |
+
)
|
| 186 |
+
if "gluten" in joined or "wheat" in joined:
|
| 187 |
+
rules.append(
|
| 188 |
+
"No gluten: no wheat roti, no double roti, no naan, no suji. Use rice, chawal ki roti,"
|
| 189 |
+
" besan (gram flour) or makai (corn) roti instead."
|
| 190 |
+
)
|
| 191 |
+
if "nut" in joined or "peanut" in joined or "badam" in joined:
|
| 192 |
+
rules.append("No nuts: no badam, akhrot, moongphali, pista, kaju, or nut-based food.")
|
| 193 |
+
if "egg" in joined or "anda" in joined:
|
| 194 |
+
rules.append("No eggs: do not use anda in any meal.")
|
| 195 |
+
if "beef" in joined:
|
| 196 |
+
rules.append("No beef: use chicken, mutton, fish, daal, or chana instead.")
|
| 197 |
+
if "vegetarian" in joined or "no meat" in joined:
|
| 198 |
+
rules.append("Vegetarian: no chicken, mutton, beef, or fish. Use daal, chana, sabzi, dahi and anda.")
|
| 199 |
+
|
| 200 |
+
return "\n".join(f"- {rule}" for rule in rules) or "- No extra foods to avoid."
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def _repair_truncated_plan(text: str) -> dict | None:
|
| 204 |
+
"""Rebuild a plan from output that was cut off mid-JSON.
|
| 205 |
+
|
| 206 |
+
Free-tier reasoning models regularly hit the token ceiling partway through
|
| 207 |
+
day 4 or 5. Everything before the cut is still valid, so the complete day
|
| 208 |
+
objects are recovered and the caller decides whether enough survived.
|
| 209 |
+
"""
|
| 210 |
+
if '"plan"' not in text:
|
| 211 |
+
return None
|
| 212 |
+
|
| 213 |
+
def _grab(field: str) -> str:
|
| 214 |
+
m = re.search(rf'"{field}"\s*:\s*"((?:[^"\\]|\\.)*)"', text)
|
| 215 |
+
return m.group(1) if m else ""
|
| 216 |
+
|
| 217 |
+
start = text.find("[", text.find('"plan"'))
|
| 218 |
+
if start == -1:
|
| 219 |
+
return None
|
| 220 |
+
|
| 221 |
+
days, depth, obj_start = [], 0, None
|
| 222 |
+
in_string, escaped = False, False
|
| 223 |
+
|
| 224 |
+
for i in range(start, len(text)):
|
| 225 |
+
ch = text[i]
|
| 226 |
+
if escaped:
|
| 227 |
+
escaped = False
|
| 228 |
+
continue
|
| 229 |
+
if ch == "\\":
|
| 230 |
+
escaped = True
|
| 231 |
+
continue
|
| 232 |
+
if ch == '"':
|
| 233 |
+
in_string = not in_string
|
| 234 |
+
continue
|
| 235 |
+
if in_string:
|
| 236 |
+
continue
|
| 237 |
+
if ch == "{":
|
| 238 |
+
if depth == 0:
|
| 239 |
+
obj_start = i
|
| 240 |
+
depth += 1
|
| 241 |
+
elif ch == "}":
|
| 242 |
+
depth -= 1
|
| 243 |
+
if depth == 0 and obj_start is not None:
|
| 244 |
+
try:
|
| 245 |
+
days.append(json.loads(text[obj_start:i + 1]))
|
| 246 |
+
except json.JSONDecodeError:
|
| 247 |
+
pass
|
| 248 |
+
obj_start = None
|
| 249 |
+
elif ch == "]" and depth == 0:
|
| 250 |
+
break
|
| 251 |
+
|
| 252 |
+
if not days:
|
| 253 |
+
return None
|
| 254 |
+
|
| 255 |
+
logger.warning("Recovered %s day(s) from a truncated diet plan", len(days))
|
| 256 |
+
return {
|
| 257 |
+
"title": _grab("title") or "Your Diet Plan",
|
| 258 |
+
"summary": _grab("summary"),
|
| 259 |
+
"plan": days,
|
| 260 |
+
"tips": [],
|
| 261 |
+
}
|
| 262 |
|
| 263 |
|
| 264 |
def _parse_diet_response(text: str) -> dict:
|
| 265 |
+
"""Parse the model response into a structured diet plan."""
|
| 266 |
cleaned = text.strip()
|
| 267 |
+
|
| 268 |
+
# Strip markdown fences the model adds despite being told not to.
|
| 269 |
if cleaned.startswith("```"):
|
| 270 |
lines = cleaned.split("\n")
|
| 271 |
cleaned = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip()
|
| 272 |
|
| 273 |
+
parsed = None
|
| 274 |
try:
|
| 275 |
parsed = json.loads(cleaned)
|
| 276 |
except json.JSONDecodeError:
|
| 277 |
+
# Fall back to the outermost JSON object in the text.
|
| 278 |
+
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
|
| 279 |
+
if match:
|
| 280 |
+
try:
|
| 281 |
+
parsed = json.loads(match.group(0))
|
| 282 |
+
except json.JSONDecodeError:
|
| 283 |
+
parsed = None
|
| 284 |
+
|
| 285 |
+
if parsed is None:
|
| 286 |
+
# The answer may simply have run out of tokens mid-object. Salvage the
|
| 287 |
+
# days that did come through rather than throwing the whole plan away.
|
| 288 |
+
parsed = _repair_truncated_plan(cleaned)
|
| 289 |
+
|
| 290 |
+
if parsed is None:
|
| 291 |
logger.warning("Failed to parse diet plan JSON")
|
| 292 |
return {}
|
| 293 |
|
| 294 |
+
if not isinstance(parsed, dict):
|
| 295 |
+
return {}
|
| 296 |
+
|
| 297 |
return {
|
| 298 |
+
"title": parsed.get("title", "Your Diet Plan"),
|
| 299 |
"summary": parsed.get("summary", ""),
|
| 300 |
"plan": parsed.get("plan", []),
|
| 301 |
"tips": parsed.get("tips", []),
|
| 302 |
}
|
| 303 |
|
| 304 |
|
| 305 |
+
# Foods that are perfectly normal in a Pakistani kitchen but are wrong for a
|
| 306 |
+
# specific condition. The model does reach for these — a live run put
|
| 307 |
+
# "Halwa puri aur chai" in a diabetes plan — so the prompt rule is backed by a
|
| 308 |
+
# hard check here. Matched on word boundaries so "puri" does not fire inside
|
| 309 |
+
# an unrelated word.
|
| 310 |
+
DIABETES_BANNED = (
|
| 311 |
+
"halwa puri", "puri", "jalebi", "gulab jamun", "mithai", "sheer khurma",
|
| 312 |
+
"sweet lassi", "rooh afza", "kheer", "cake", "biscuit", "soft drink",
|
| 313 |
+
"حلوہ پوری", "پوری", "جلیبی", "مٹھائی", "گلاب جامن", "کھیر", "میٹھی لسی",
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
HYPERTENSION_BANNED = (
|
| 317 |
+
"achaar", "achar", "pickle", "papad", "chips", "instant noodles",
|
| 318 |
+
"processed meat", "salted", "namkeen",
|
| 319 |
+
"اچار", "پاپڑ", "چپس", "نمکین",
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
# What each restriction actually rules out, in both the Roman-Urdu and Urdu
|
| 324 |
+
# spellings the models produce. A stated restriction may be an allergy, so a
|
| 325 |
+
# single violation rejects the plan — unlike the "unfamiliar food" check,
|
| 326 |
+
# which tolerates a stray mention.
|
| 327 |
+
RESTRICTION_FOODS: dict[str, tuple[str, ...]] = {
|
| 328 |
+
"egg": ("egg", "eggs", "anda", "ande", "omelette", "omelet", "bhurji",
|
| 329 |
+
"انڈا", "انڈے", "آملیٹ", "بھجیا"),
|
| 330 |
+
"dairy": ("milk", "yogurt", "yoghurt", "curd", "dahi", "paneer", "cheese",
|
| 331 |
+
"butter", "cream", "lassi", "raita", "karhi", "kheer", "doodh",
|
| 332 |
+
"دودھ", "دہی", "پنیر", "مکھن", "کریم", "لسی", "رائتہ", "رائتے",
|
| 333 |
+
"کڑھی", "کھیر"),
|
| 334 |
+
"gluten": ("wheat", "roti", "chapati", "chapatti", "naan", "paratha",
|
| 335 |
+
"bread", "suji", "semolina", "maida", "dalia",
|
| 336 |
+
"گندم", "روٹی", "چپاتی", "نان", "پراٹھا", "سوجی", "میدہ"),
|
| 337 |
+
"nuts": ("almond", "badam", "walnut", "akhrot", "peanut", "moongphali",
|
| 338 |
+
"cashew", "kaju", "pista", "pistachio",
|
| 339 |
+
"بادام", "اخروٹ", "مونگ پھلی", "کاجو", "پستہ"),
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
# Dishes that are gluten-free despite containing a word from the list above:
|
| 343 |
+
# corn/rice/gram-flour breads, and dalia made from rice rather than wheat.
|
| 344 |
+
GLUTEN_SAFE_PHRASES = (
|
| 345 |
+
"makai ki roti", "chawal ki roti", "besan ki roti", "corn roti",
|
| 346 |
+
"chawal ka dalia", "rice porridge",
|
| 347 |
+
"مکئی کی روٹی", "چاول کی روٹی", "بیسن کی روٹی", "چاول کا دلیہ",
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
RESTRICTION_ALIASES = {
|
| 351 |
+
"egg": "egg", "anda": "egg", "انڈا": "egg",
|
| 352 |
+
"dairy": "dairy", "lactose": "dairy", "milk": "dairy", "دودھ": "dairy",
|
| 353 |
+
"gluten": "gluten", "wheat": "gluten", "roti": "gluten", "گندم": "gluten",
|
| 354 |
+
"nut": "nuts", "nuts": "nuts", "peanut": "nuts", "badam": "nuts", "بادام": "nuts",
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def _violates_restrictions(days: list, restrictions: list[str]) -> list[str]:
|
| 359 |
+
"""Foods in the plan that the user said they cannot eat."""
|
| 360 |
+
if not restrictions:
|
| 361 |
+
return []
|
| 362 |
+
|
| 363 |
+
keys = set()
|
| 364 |
+
joined = " ".join(restrictions).lower()
|
| 365 |
+
for alias, key in RESTRICTION_ALIASES.items():
|
| 366 |
+
if alias in joined:
|
| 367 |
+
keys.add(key)
|
| 368 |
+
if not keys:
|
| 369 |
+
return []
|
| 370 |
+
|
| 371 |
+
found: list[str] = []
|
| 372 |
+
for text in _meal_texts(days):
|
| 373 |
+
cleaned = text
|
| 374 |
+
for safe in GLUTEN_SAFE_PHRASES:
|
| 375 |
+
cleaned = cleaned.replace(safe, " ")
|
| 376 |
+
for key in keys:
|
| 377 |
+
for food in RESTRICTION_FOODS[key]:
|
| 378 |
+
if re.search(rf"(?<!\w){re.escape(food)}(?!\w)", cleaned):
|
| 379 |
+
found.append(f"{key}:{food}")
|
| 380 |
+
return found
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def _meal_texts(days: list) -> list[str]:
|
| 384 |
+
"""Every meal name + description in the plan, lowercased."""
|
| 385 |
+
texts = []
|
| 386 |
+
for day in days:
|
| 387 |
+
if not isinstance(day, dict):
|
| 388 |
+
continue
|
| 389 |
+
meals = [day.get("breakfast"), day.get("lunch"), day.get("dinner")]
|
| 390 |
+
meals += day.get("snacks") if isinstance(day.get("snacks"), list) else []
|
| 391 |
+
for meal in meals:
|
| 392 |
+
if isinstance(meal, dict):
|
| 393 |
+
texts.append(f"{meal.get('name', '')} {meal.get('description', '')}".lower())
|
| 394 |
+
return texts
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def _unsafe_for_condition(days: list, condition: str, goals: str) -> list[str]:
|
| 398 |
+
"""Foods in the plan that are unsafe for the stated condition."""
|
| 399 |
+
context = f"{condition} {goals}"
|
| 400 |
+
banned: tuple[str, ...] = ()
|
| 401 |
+
if _has_any(context, DIABETES_TERMS):
|
| 402 |
+
banned += DIABETES_BANNED
|
| 403 |
+
if _has_any(context, HYPERTENSION_TERMS):
|
| 404 |
+
banned += HYPERTENSION_BANNED
|
| 405 |
+
if not banned:
|
| 406 |
+
return []
|
| 407 |
+
|
| 408 |
+
found = []
|
| 409 |
+
for text in _meal_texts(days):
|
| 410 |
+
for item in banned:
|
| 411 |
+
if re.search(rf"(?<!\w){re.escape(item)}(?!\w)", text):
|
| 412 |
+
found.append(item)
|
| 413 |
+
return found
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def _counts_unfamiliar_foods(days: list) -> int:
|
| 417 |
+
"""How many meals mention food a Pakistani household would not recognise."""
|
| 418 |
+
hits = 0
|
| 419 |
+
for day in days:
|
| 420 |
+
if not isinstance(day, dict):
|
| 421 |
+
continue
|
| 422 |
+
meals = [day.get("breakfast"), day.get("lunch"), day.get("dinner")]
|
| 423 |
+
meals += day.get("snacks") if isinstance(day.get("snacks"), list) else []
|
| 424 |
+
for meal in meals:
|
| 425 |
+
if not isinstance(meal, dict):
|
| 426 |
+
continue
|
| 427 |
+
text = f"{meal.get('name', '')} {meal.get('description', '')}".lower()
|
| 428 |
+
if any(food in text for food in UNFAMILIAR_FOODS):
|
| 429 |
+
hits += 1
|
| 430 |
+
return hits
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
URDU_DAY_NAMES = (
|
| 434 |
+
"پہلا دن", "دوسرا دن", "تیسرا دن", "چوتھا دن",
|
| 435 |
+
"پانچواں دن", "چھٹا دن", "ساتواں دن",
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
def _day_label(index: int, language: str) -> str:
|
| 440 |
+
"""Human day name for position `index` (1-based)."""
|
| 441 |
+
if language == "ur" and 1 <= index <= len(URDU_DAY_NAMES):
|
| 442 |
+
return URDU_DAY_NAMES[index - 1]
|
| 443 |
+
return f"Day {index}"
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def _top_up_days(days: list, language: str) -> list:
|
| 447 |
+
"""Extend a short plan to 7 days by cycling the days that came through.
|
| 448 |
+
|
| 449 |
+
Repeating meals across a week is normal in a real household plan, and the
|
| 450 |
+
user keeps the food that was chosen for them rather than being handed the
|
| 451 |
+
generic week instead.
|
| 452 |
+
"""
|
| 453 |
+
filled = [dict(d) for d in days if isinstance(d, dict)]
|
| 454 |
+
if not filled:
|
| 455 |
+
return days
|
| 456 |
+
|
| 457 |
+
while len(filled) < 7:
|
| 458 |
+
filled.append(dict(filled[len(filled) % len(days)]))
|
| 459 |
+
|
| 460 |
+
for i, day in enumerate(filled, start=1):
|
| 461 |
+
day["day"] = _day_label(i, language)
|
| 462 |
+
return filled
|
| 463 |
+
|
| 464 |
+
|
| 465 |
def _normalize_diet_plan(
|
| 466 |
plan: dict,
|
| 467 |
condition: str = "general wellness",
|
| 468 |
dietary_preferences: str = "balanced",
|
| 469 |
restrictions: list[str] | None = None,
|
| 470 |
goals: str = "general health",
|
| 471 |
+
language: str = "en",
|
| 472 |
) -> dict:
|
| 473 |
+
"""Keep generated plans complete, local, and safe."""
|
| 474 |
restrictions = restrictions or []
|
| 475 |
days = plan.get("plan") if isinstance(plan.get("plan"), list) else []
|
| 476 |
+
|
| 477 |
+
# A plan cut short at day 6 is still the user's own personalized plan.
|
| 478 |
+
# Topping it up beats throwing it away for the generic one; only a badly
|
| 479 |
+
# short answer falls all the way back.
|
| 480 |
+
if 5 <= len(days) < 7:
|
| 481 |
+
logger.info("Diet plan had %s days; topping up to 7", len(days))
|
| 482 |
+
days = _top_up_days(days, language)
|
| 483 |
+
|
| 484 |
if len(days) != 7:
|
| 485 |
logger.warning("Diet plan had %s days; using validated fallback", len(days))
|
| 486 |
+
return _fallback_diet_plan(condition, dietary_preferences, restrictions, goals, language)
|
| 487 |
|
| 488 |
for idx, day in enumerate(days[:7], start=1):
|
| 489 |
if not isinstance(day, dict):
|
| 490 |
+
return _fallback_diet_plan(condition, dietary_preferences, restrictions, goals, language)
|
| 491 |
day["day"] = day.get("day") or f"Day {idx}"
|
| 492 |
for meal_name in ("breakfast", "lunch", "dinner"):
|
| 493 |
meal = day.get(meal_name)
|
| 494 |
if not isinstance(meal, dict) or not meal.get("name"):
|
| 495 |
logger.warning("Diet plan missing %s on day %s; using fallback", meal_name, idx)
|
| 496 |
+
return _fallback_diet_plan(condition, dietary_preferences, restrictions, goals, language)
|
| 497 |
if not isinstance(day.get("snacks"), list):
|
| 498 |
day["snacks"] = []
|
| 499 |
|
| 500 |
+
# The client's actual complaint: plans full of food nobody here eats.
|
| 501 |
+
# A stray mention is tolerable; a plan built on them is not.
|
| 502 |
+
unfamiliar = _counts_unfamiliar_foods(days)
|
| 503 |
+
if unfamiliar >= 4:
|
| 504 |
+
logger.warning(
|
| 505 |
+
"Diet plan used %s unfamiliar foods; falling back to the local plan", unfamiliar
|
| 506 |
+
)
|
| 507 |
+
return _fallback_diet_plan(condition, dietary_preferences, restrictions, goals, language)
|
| 508 |
+
|
| 509 |
+
# Safety, not taste: one mithai or halwa puri in a diabetic plan is enough
|
| 510 |
+
# to reject the whole thing.
|
| 511 |
+
unsafe = _unsafe_for_condition(days, condition, goals)
|
| 512 |
+
if unsafe:
|
| 513 |
+
logger.warning(
|
| 514 |
+
"Diet plan contained food unsafe for '%s' (%s); using the validated fallback",
|
| 515 |
+
condition, ", ".join(sorted(set(unsafe))[:5]),
|
| 516 |
+
)
|
| 517 |
+
return _fallback_diet_plan(condition, dietary_preferences, restrictions, goals, language)
|
| 518 |
+
|
| 519 |
+
# A stated restriction may be an allergy. The model does ignore these —
|
| 520 |
+
# a live run returned anda to someone who had excluded egg — so one
|
| 521 |
+
# violation is enough to reject the plan.
|
| 522 |
+
violations = _violates_restrictions(days, restrictions)
|
| 523 |
+
if violations:
|
| 524 |
+
logger.warning(
|
| 525 |
+
"Diet plan broke a stated restriction (%s); using the validated fallback",
|
| 526 |
+
", ".join(sorted(set(violations))[:5]),
|
| 527 |
+
)
|
| 528 |
+
return _fallback_diet_plan(condition, dietary_preferences, restrictions, goals, language)
|
| 529 |
+
|
| 530 |
tips = [str(t) for t in plan.get("tips", []) if str(t).strip()]
|
| 531 |
context = f"{condition} {goals}"
|
| 532 |
+
urdu = language == "ur"
|
| 533 |
|
| 534 |
if _has_any(context, HYPERTENSION_TERMS):
|
| 535 |
+
tips = _merge_tips(tips, _BP_TIPS_UR if urdu else _BP_TIPS_EN)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
if _has_any(context, DIABETES_TERMS):
|
| 537 |
+
tips = _merge_tips(tips, _SUGAR_TIPS_UR if urdu else _SUGAR_TIPS_EN)
|
|
|
|
|
|
|
|
|
|
| 538 |
if _has_any(context, KIDNEY_TERMS):
|
| 539 |
+
tips = _merge_tips(tips, _KIDNEY_TIPS_UR if urdu else _KIDNEY_TIPS_EN)
|
|
|
|
|
|
|
|
|
|
| 540 |
if condition and condition.lower() != "general wellness":
|
| 541 |
+
tips = _merge_tips(tips, [_DOCTOR_TIP_UR if urdu else _DOCTOR_TIP_EN])
|
| 542 |
+
|
| 543 |
+
default_title = "آپ کا کھانے کا پلان" if urdu else "Your 7-Day Meal Plan"
|
| 544 |
+
default_summary = (
|
| 545 |
+
"سات دن کا آسان پلان، روزمرہ کے پاکستانی گھریلو کھانوں پر مبنی۔"
|
| 546 |
+
if urdu
|
| 547 |
+
else "A simple 7-day plan built from everyday Pakistani home food."
|
| 548 |
+
)
|
| 549 |
|
| 550 |
return {
|
| 551 |
+
"title": plan.get("title") or default_title,
|
| 552 |
+
"summary": plan.get("summary") or default_summary,
|
| 553 |
"plan": days[:7],
|
| 554 |
"tips": tips[:6],
|
| 555 |
}
|
| 556 |
|
| 557 |
|
| 558 |
+
_DOCTOR_TIP_EN = (
|
| 559 |
+
"Show this plan to your doctor or a dietitian before following it, "
|
| 560 |
+
"especially if you take any medicine."
|
| 561 |
+
)
|
| 562 |
+
_DOCTOR_TIP_UR = (
|
| 563 |
+
"اس پلان پر عمل کرنے سے پہلے اسے اپنے ڈاکٹر یا ماہرِ غذا کو ضرور دکھائیں، "
|
| 564 |
+
"خاص طور پر اگر آپ کوئی دوا لے رہے ہیں۔"
|
| 565 |
+
)
|
| 566 |
+
|
| 567 |
+
_BP_TIPS_EN = [
|
| 568 |
+
"Use less namak (salt) while cooking, and do not add extra salt at the table.",
|
| 569 |
+
"Avoid achaar, papad, chips, packet noodles and other salty snacks.",
|
| 570 |
+
"If you have any kidney problem or take blood pressure medicine, ask your doctor before eating a lot of fruit.",
|
| 571 |
+
]
|
| 572 |
+
_BP_TIPS_UR = [
|
| 573 |
+
"کھانا پکاتے وقت نمک کم استعمال کریں، اور اوپر سے مزید نمک نہ ڈالیں۔",
|
| 574 |
+
"اچار، پاپڑ، چپس، پیکٹ والے نوڈلز اور نمکین اسنیکس سے پرہیز کریں۔",
|
| 575 |
+
"اگر گردے کی تکلیف ہے یا بلڈ پریشر کی دوا لیتے ہیں تو زیادہ پھل کھانے سے پہلے ڈاکٹر سے پوچھیں۔",
|
| 576 |
+
]
|
| 577 |
+
|
| 578 |
+
_SUGAR_TIPS_EN = [
|
| 579 |
+
"Avoid soft drinks, packet juice and mithai. Take chai with little or no sugar.",
|
| 580 |
+
"Eat roti or rice together with daal, sabzi, anda or dahi — not on their own.",
|
| 581 |
+
]
|
| 582 |
+
_SUGAR_TIPS_UR = [
|
| 583 |
+
"کولڈ ڈرنک، پیکٹ والا جوس اور مٹھائی نہ لیں۔ چائے میں چینی بہت کم یا بالکل نہ ڈالیں۔",
|
| 584 |
+
"روٹی یا چاول ہمیشہ دال، سبزی، انڈے یا دہی کے ساتھ کھائیں، اکیلے نہیں۔",
|
| 585 |
+
]
|
| 586 |
+
|
| 587 |
+
_KIDNEY_TIPS_EN = [
|
| 588 |
+
"A kidney patient's diet must be set by their own doctor — salt, potassium, protein and water all change.",
|
| 589 |
+
"Do not increase fruit, daal or milk on your own if you have kidney disease.",
|
| 590 |
+
]
|
| 591 |
+
_KIDNEY_TIPS_UR = [
|
| 592 |
+
"گردے کے مریض کی خوراک اُس کا اپنا ڈاکٹر طے کرے — نمک، پوٹاشیم، پروٹین اور پانی سب بدلتے ہیں۔",
|
| 593 |
+
"گردے کی بیماری میں پھل، دال یا دودھ اپنی مرضی سے نہ بڑھائیں۔",
|
| 594 |
+
]
|
| 595 |
+
|
| 596 |
+
|
| 597 |
def _merge_tips(existing: list[str], required: list[str]) -> list[str]:
|
| 598 |
merged = list(existing)
|
| 599 |
comparable = " ".join(_tip_key(tip) for tip in existing)
|
|
|
|
| 604 |
|
| 605 |
|
| 606 |
def _tip_key(tip: str) -> str:
|
| 607 |
+
words = re.sub(r"[^a-z0-9-ۿ]+", " ", tip.lower()).strip().split()
|
| 608 |
return " ".join(words[:5])
|
| 609 |
|
| 610 |
|
| 611 |
+
# ── Offline fallback: a plain Pakistani week ──────────────────────────────
|
| 612 |
+
#
|
| 613 |
+
# Used when OpenRouter is unreachable or returns something unusable. It is the
|
| 614 |
+
# plan the client will actually see on a bad day, so it is written to the same
|
| 615 |
+
# standard as the prompt above: real home food, real portions, plain words.
|
| 616 |
+
|
| 617 |
+
def _meal(name: str, description: str, calories: int, nutrients: str) -> dict:
|
| 618 |
+
return {
|
| 619 |
+
"name": name,
|
| 620 |
+
"description": description,
|
| 621 |
+
"calories": calories,
|
| 622 |
+
"nutrients": nutrients,
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
|
| 626 |
+
def _base_week_en(
|
| 627 |
+
vegetarian: bool, dairy_free: bool, no_egg: bool, gluten_free: bool = False
|
| 628 |
+
) -> list[dict]:
|
| 629 |
+
# Makai (corn) roti is the everyday gluten-free bread here — far more
|
| 630 |
+
# familiar to this audience than any imported substitute.
|
| 631 |
+
roti = "Makai ki Roti" if gluten_free else "Roti"
|
| 632 |
+
roti_low = "makai ki roti" if gluten_free else "roti"
|
| 633 |
+
paratha = "Makai ki Roti" if gluten_free else "Paratha"
|
| 634 |
+
paratha_low = "makai ki roti" if gluten_free else "paratha"
|
| 635 |
+
bread = "Chawal" if gluten_free else "Double Roti"
|
| 636 |
+
bread_desc = "a small bowl of rice" if gluten_free else "two slices of bread"
|
| 637 |
+
dalia_name = "Chawal ka Dalia" if gluten_free else "Suji Dalia"
|
| 638 |
+
dalia_grain = "rice porridge" if gluten_free else "wheat porridge"
|
| 639 |
+
|
| 640 |
+
egg_breakfast = (
|
| 641 |
+
_meal(f"Aloo {paratha} aur Chai", f"One {paratha_low} with a cup of tea.", 420, "Energy")
|
| 642 |
+
if no_egg
|
| 643 |
+
else _meal(f"Anda aur {roti}", f"Two eggs with one {roti_low} and a cup of tea.", 400, "Protein and energy")
|
| 644 |
+
)
|
| 645 |
+
dahi_item = (
|
| 646 |
+
_meal("Kela (banana)", "One banana.", 100, "Energy and potassium")
|
| 647 |
+
if dairy_free
|
| 648 |
+
else _meal("Dahi (yogurt)", "One small bowl of plain yogurt.", 100, "Protein and good for the stomach")
|
| 649 |
+
)
|
| 650 |
+
meat_lunch = (
|
| 651 |
+
_meal("Chana Chaat", f"Boiled chickpeas with onion, tomato and lemon, with one {roti_low}.", 480, "Protein and fibre")
|
| 652 |
+
if vegetarian
|
| 653 |
+
else _meal(f"Chicken Salan aur {roti}", f"Chicken curry with two {roti_low} and a slice of onion and cucumber.", 550, "Protein and energy")
|
| 654 |
+
)
|
| 655 |
+
meat_dinner = (
|
| 656 |
+
_meal("Daal aur Chawal", "Lentils with one bowl of rice.", 480, "Protein and energy")
|
| 657 |
+
if vegetarian
|
| 658 |
+
else _meal(f"Chicken Yakhni aur {roti}", f"Light chicken soup with one {roti_low}.", 420, "Protein, easy to digest")
|
| 659 |
+
)
|
| 660 |
+
# Dalia is normally cooked in milk and biryani is normally served with
|
| 661 |
+
# raita — both have to drop the dairy when the user cannot have it.
|
| 662 |
+
dalia_liquid = "water" if dairy_free else "water or milk"
|
| 663 |
+
biryani_side = "salad" if dairy_free else "raita or salad"
|
| 664 |
+
|
| 665 |
+
return [
|
| 666 |
+
{
|
| 667 |
+
"day": "Day 1",
|
| 668 |
+
"breakfast": egg_breakfast,
|
| 669 |
+
"lunch": _meal("Daal Chawal", "Lentils with one bowl of rice and a plain salad.", 500, "Protein and energy"),
|
| 670 |
+
"dinner": _meal(f"Mix Sabzi aur {roti}", f"Mixed vegetable curry with two {roti_low}.", 450, "Vitamins and fibre"),
|
| 671 |
+
"snacks": [dahi_item],
|
| 672 |
+
},
|
| 673 |
+
{
|
| 674 |
+
"day": "Day 2",
|
| 675 |
+
"breakfast": _meal(
|
| 676 |
+
f"{bread} aur Anda" if not no_egg else f"{bread} aur Jam",
|
| 677 |
+
f"An omelette with {bread_desc} and tea." if not no_egg else f"Jam with {bread_desc} and tea.",
|
| 678 |
+
380,
|
| 679 |
+
"Protein and energy" if not no_egg else "Energy",
|
| 680 |
+
),
|
| 681 |
+
"lunch": meat_lunch,
|
| 682 |
+
"dinner": _meal("Khichdi", "Soft rice and lentils cooked together, with a little plain salad.", 430, "Light and easy to digest"),
|
| 683 |
+
"snacks": [_meal("Seb (apple)", "One apple.", 90, "Fibre and vitamins")],
|
| 684 |
+
},
|
| 685 |
+
{
|
| 686 |
+
"day": "Day 3",
|
| 687 |
+
"breakfast": _meal(dalia_name, f"A bowl of {dalia_grain} cooked in {dalia_liquid}, with a banana.", 350, "Energy and fibre"),
|
| 688 |
+
"lunch": _meal(f"Aloo Gosht aur {roti}" if not vegetarian else f"Aloo Palak aur {roti}",
|
| 689 |
+
f"Potato curry with meat and two {roti_low}." if not vegetarian else f"Potato and spinach curry with two {roti_low}.",
|
| 690 |
+
520, "Protein and iron"),
|
| 691 |
+
"dinner": _meal(f"Bhindi aur {roti}", f"Okra curry with two {roti_low}.", 420, "Fibre and vitamins"),
|
| 692 |
+
"snacks": [_meal("Malta ya Kela", "One orange or one banana.", 90, "Vitamin C")],
|
| 693 |
+
},
|
| 694 |
+
{
|
| 695 |
+
"day": "Day 4",
|
| 696 |
+
"breakfast": _meal(
|
| 697 |
+
f"Anda Bhurji aur {roti}" if not no_egg else "Chana Chaat Nashta",
|
| 698 |
+
f"Scrambled eggs with one {roti_low} and tea." if not no_egg else "A small bowl of boiled chickpeas with lemon and tea.",
|
| 699 |
+
400,
|
| 700 |
+
"Protein",
|
| 701 |
+
),
|
| 702 |
+
"lunch": _meal(f"Chana Daal aur {roti}", f"Split chickpea lentils with two {roti_low} and salad.", 490, "Protein and fibre"),
|
| 703 |
+
"dinner": _meal("Vegetable Pulao", "Rice cooked with peas, carrot and potato.", 470, "Energy and vitamins"),
|
| 704 |
+
"snacks": [dahi_item],
|
| 705 |
+
},
|
| 706 |
+
{
|
| 707 |
+
"day": "Day 5",
|
| 708 |
+
"breakfast": _meal(f"{paratha} aur Dahi" if not dairy_free else f"{paratha} aur Chai",
|
| 709 |
+
f"One {paratha_low} with a small bowl of yogurt." if not dairy_free else f"One {paratha_low} with a cup of tea.",
|
| 710 |
+
430, "Energy"),
|
| 711 |
+
"lunch": meat_dinner if vegetarian else _meal(f"Machli aur {roti}", f"Fried or baked fish with two {roti_low} and salad.", 520, "Protein and good fats"),
|
| 712 |
+
"dinner": _meal(f"Palak aur {roti}", f"Spinach curry with two {roti_low}.", 400, "Iron and fibre"),
|
| 713 |
+
"snacks": [_meal("Gajar ya Kheera", "A few carrot or cucumber sticks.", 60, "Fibre and water")],
|
| 714 |
+
},
|
| 715 |
+
{
|
| 716 |
+
"day": "Day 6",
|
| 717 |
+
"breakfast": _meal(f"Doodh aur {bread}" if not dairy_free else f"Chai aur {bread}",
|
| 718 |
+
f"A glass of milk with {bread_desc}." if not dairy_free else f"A cup of tea with {bread_desc}.",
|
| 719 |
+
350, "Calcium and energy" if not dairy_free else "Energy"),
|
| 720 |
+
"lunch": _meal("Rajma ya Lobia Chawal", "Kidney beans or black-eyed peas with one bowl of rice.", 520, "Protein and fibre"),
|
| 721 |
+
"dinner": _meal("Karhi Chawal" if not dairy_free else f"Daal aur {roti}",
|
| 722 |
+
"Yogurt curry with one bowl of rice." if not dairy_free else f"Lentils with two {roti_low}.",
|
| 723 |
+
460, "Protein and energy"),
|
| 724 |
+
"snacks": [_meal("Kela (banana)", "One banana.", 100, "Energy")],
|
| 725 |
+
},
|
| 726 |
+
{
|
| 727 |
+
"day": "Day 7",
|
| 728 |
+
"breakfast": egg_breakfast,
|
| 729 |
+
"lunch": _meal("Biryani (small plate)" if not vegetarian else "Sabzi Biryani (small plate)",
|
| 730 |
+
f"One small plate of biryani with {biryani_side}.", 600, "Energy and protein"),
|
| 731 |
+
"dinner": _meal(f"Daal aur {roti}", f"Lentils with two {roti_low} and a plain salad.", 420, "Protein and fibre"),
|
| 732 |
+
"snacks": [_meal("Mausami Phal", "Any seasonal fruit.", 90, "Vitamins")],
|
| 733 |
+
},
|
| 734 |
+
]
|
| 735 |
+
|
| 736 |
+
|
| 737 |
+
def _base_week_ur(
|
| 738 |
+
vegetarian: bool, dairy_free: bool, no_egg: bool, gluten_free: bool = False
|
| 739 |
+
) -> list[dict]:
|
| 740 |
+
roti = "مکئی کی روٹی" if gluten_free else "روٹی"
|
| 741 |
+
paratha = "مکئی کی روٹی" if gluten_free else "پراٹھا"
|
| 742 |
+
bread = "چاول" if gluten_free else "ڈبل روٹی"
|
| 743 |
+
bread_desc = "ایک چھوٹی پیالی چاول" if gluten_free else "ڈبل روٹی کے دو سلائس"
|
| 744 |
+
dalia_name = "چاول کا دلیہ" if gluten_free else "سوجی کا دلیہ"
|
| 745 |
+
|
| 746 |
+
egg_breakfast = (
|
| 747 |
+
_meal(f"آلو {paratha} اور چائے", f"ایک {paratha} اور ایک کپ چائے۔", 420, "توانائی")
|
| 748 |
+
if no_egg
|
| 749 |
+
else _meal(f"انڈا اور {roti}", f"دو انڈے، ایک {roti} اور ایک کپ چائے۔", 400, "پروٹین اور توانائی")
|
| 750 |
+
)
|
| 751 |
+
dahi_item = (
|
| 752 |
+
_meal("کیلا", "ایک کیلا۔", 100, "توانائی")
|
| 753 |
+
if dairy_free
|
| 754 |
+
else _meal("دہی", "ایک چھوٹی پیالی سادہ دہی۔", 100, "پروٹین، معدے کے لیے اچھا")
|
| 755 |
+
)
|
| 756 |
+
meat_lunch = (
|
| 757 |
+
_meal("چنا چاٹ", f"ابلے ہوئے چنے پیاز، ٹماٹر اور لیموں کے ساتھ، ایک {roti}۔", 480, "پروٹین اور فائبر")
|
| 758 |
+
if vegetarian
|
| 759 |
+
else _meal(f"چکن سالن اور {roti}", f"چکن سالن، دو {roti} اور تھوڑا سا سلاد۔", 550, "پروٹین اور توانائی")
|
| 760 |
+
)
|
| 761 |
+
meat_dinner = (
|
| 762 |
+
_meal("دال اور چاول", "دال کے ساتھ ایک پیالی چاول۔", 480, "پروٹین اور توانائی")
|
| 763 |
+
if vegetarian
|
| 764 |
+
else _meal(f"چکن یخنی اور {roti}", f"ہلکی چکن یخنی اور ایک {roti}۔", 420, "پروٹین، آسانی سے ہضم")
|
| 765 |
+
)
|
| 766 |
+
dalia_liquid = "پانی" if dairy_free else "پانی یا دودھ"
|
| 767 |
+
biryani_side = "سلاد" if dairy_free else "رائتے یا سلاد"
|
| 768 |
+
|
| 769 |
+
return [
|
| 770 |
+
{
|
| 771 |
+
"day": "پہلا دن",
|
| 772 |
+
"breakfast": egg_breakfast,
|
| 773 |
+
"lunch": _meal("دال چاول", "دال کے ساتھ ایک پیالی چاول اور سادہ سلاد۔", 500, "پروٹین اور توانائی"),
|
| 774 |
+
"dinner": _meal(f"مکس سبزی اور {roti}", f"ملی جلی سبزی کے ساتھ دو {roti}۔", 450, "وٹامن اور فائبر"),
|
| 775 |
+
"snacks": [dahi_item],
|
| 776 |
+
},
|
| 777 |
+
{
|
| 778 |
+
"day": "دوسرا دن",
|
| 779 |
+
"breakfast": _meal(
|
| 780 |
+
f"{bread} اور انڈا" if not no_egg else f"{bread} اور جام",
|
| 781 |
+
f"{bread_desc}، آملیٹ اور چائے۔" if not no_egg else f"{bread_desc} جام کے ساتھ اور چائے۔",
|
| 782 |
+
380,
|
| 783 |
+
"پروٹین اور توانائی" if not no_egg else "توانائی",
|
| 784 |
+
),
|
| 785 |
+
"lunch": meat_lunch,
|
| 786 |
+
"dinner": _meal("کھچڑی", "نرم چاول اور دال ملا کر پکائی ہوئی، ساتھ سادہ سلاد۔", 430, "ہلکی اور جلد ہضم"),
|
| 787 |
+
"snacks": [_meal("سیب", "ایک سیب۔", 90, "فائبر اور وٹامن")],
|
| 788 |
+
},
|
| 789 |
+
{
|
| 790 |
+
"day": "تیسرا دن",
|
| 791 |
+
"breakfast": _meal(dalia_name, f"ایک پیالی دلیہ {dalia_liquid} میں پکا کر، ساتھ ایک کیلا۔", 350, "توانائی اور فائبر"),
|
| 792 |
+
"lunch": _meal(f"آلو گوشت اور {roti}" if not vegetarian else f"آلو پالک اور {roti}",
|
| 793 |
+
f"آلو گوشت کا سالن اور دو {roti}۔" if not vegetarian else f"آلو پالک کا سالن اور دو {roti}۔",
|
| 794 |
+
520, "پروٹین اور آئرن"),
|
| 795 |
+
"dinner": _meal(f"بھنڈی اور {roti}", f"بھنڈی کی سبزی کے ساتھ دو {roti}۔", 420, "فائبر اور وٹامن"),
|
| 796 |
+
"snacks": [_meal("مالٹا یا کیلا", "ایک مالٹا یا ایک کیلا۔", 90, "وٹامن سی")],
|
| 797 |
+
},
|
| 798 |
+
{
|
| 799 |
+
"day": "چوتھا دن",
|
| 800 |
+
"breakfast": _meal(
|
| 801 |
+
f"انڈا بھجیا اور {roti}" if not no_egg else "چنے کا ناشتہ",
|
| 802 |
+
f"بھنا ہوا انڈا، ایک {roti} اور چائے۔" if not no_egg else "ایک چھوٹی پیالی ابلے چنے لیموں کے ساتھ اور چائے۔",
|
| 803 |
+
400,
|
| 804 |
+
"پروٹین",
|
| 805 |
+
),
|
| 806 |
+
"lunch": _meal(f"چنے کی دال اور {roti}", f"چنے کی دال، دو {roti} اور سلاد۔", 490, "پروٹین اور فائبر"),
|
| 807 |
+
"dinner": _meal("سبزی پلاؤ", "مٹر، گاجر اور آلو کے ساتھ پکے ہوئے چاول۔", 470, "توانائی اور وٹامن"),
|
| 808 |
+
"snacks": [dahi_item],
|
| 809 |
+
},
|
| 810 |
+
{
|
| 811 |
+
"day": "پانچواں دن",
|
| 812 |
+
"breakfast": _meal(f"{paratha} اور دہی" if not dairy_free else f"{paratha} اور چائے",
|
| 813 |
+
f"ایک {paratha} اور ایک چھوٹی پیالی دہی۔" if not dairy_free else f"ایک {paratha} اور ایک کپ چائے۔",
|
| 814 |
+
430, "توانائی"),
|
| 815 |
+
"lunch": meat_dinner if vegetarian else _meal(f"مچھلی اور {roti}", f"تلی یا پکی ہوئی مچھلی، دو {roti} اور سلاد۔", 520, "پروٹین اور اچھی چکنائی"),
|
| 816 |
+
"dinner": _meal(f"پالک اور {roti}", f"پالک کی سبزی کے ساتھ دو {roti}۔", 400, "آئرن اور فائبر"),
|
| 817 |
+
"snacks": [_meal("گاجر یا کھیرا", "چند گاجر یا کھیرے کے ٹکڑے۔", 60, "فائبر اور پانی")],
|
| 818 |
+
},
|
| 819 |
+
{
|
| 820 |
+
"day": "چھٹا دن",
|
| 821 |
+
"breakfast": _meal(f"دودھ اور {bread}" if not dairy_free else f"چائے اور {bread}",
|
| 822 |
+
f"ایک گلاس دودھ اور {bread_desc}۔" if not dairy_free else f"ایک کپ چائے اور {bread_desc}۔",
|
| 823 |
+
350, "کیلشیم اور توانائی" if not dairy_free else "توانائی"),
|
| 824 |
+
"lunch": _meal("راجما یا لوبیا چاول", "راجما یا لوبیا کے ساتھ ایک پیالی چاول۔", 520, "پروٹین اور فائبر"),
|
| 825 |
+
"dinner": _meal("کڑھی چاول" if not dairy_free else f"دال اور {roti}",
|
| 826 |
+
"دہی کی کڑھی کے ساتھ ایک پیالی چاول۔" if not dairy_free else f"دال کے ساتھ دو {roti}۔",
|
| 827 |
+
460, "پروٹین اور توانائی"),
|
| 828 |
+
"snacks": [_meal("کیلا", "ایک کیلا۔", 100, "توانائی")],
|
| 829 |
+
},
|
| 830 |
+
{
|
| 831 |
+
"day": "ساتواں دن",
|
| 832 |
+
"breakfast": egg_breakfast,
|
| 833 |
+
"lunch": _meal("بریانی (چھوٹی پلیٹ)" if not vegetarian else "سبزی بریانی (چھوٹی پلیٹ)",
|
| 834 |
+
f"ایک چھوٹی پلیٹ بریانی {biryani_side} کے ساتھ۔", 600, "توانائی اور پروٹین"),
|
| 835 |
+
"dinner": _meal(f"دال اور {roti}", f"دال، دو {roti} اور سادہ سلاد۔", 420, "پروٹین اور فائبر"),
|
| 836 |
+
"snacks": [_meal("موسمی پھل", "کوئی بھی موسمی پھل۔", 90, "وٹامن")],
|
| 837 |
+
},
|
| 838 |
+
]
|
| 839 |
+
|
| 840 |
+
|
| 841 |
def _fallback_diet_plan(
|
| 842 |
condition: str = "general wellness",
|
| 843 |
dietary_preferences: str = "balanced",
|
| 844 |
restrictions: list[str] | None = None,
|
| 845 |
goals: str = "general health",
|
| 846 |
+
language: str = "en",
|
| 847 |
) -> dict:
|
| 848 |
+
"""A validated everyday Pakistani plan, used when the model is unavailable."""
|
| 849 |
restrictions = restrictions or []
|
| 850 |
context = f"{condition} {goals}".lower()
|
| 851 |
restrictions_text = " ".join(restrictions).lower()
|
| 852 |
+
prefs = dietary_preferences.lower()
|
| 853 |
+
urdu = language == "ur"
|
| 854 |
+
|
| 855 |
+
dairy_free = any(t in restrictions_text for t in ("dairy", "lactose", "milk", "دودھ"))
|
| 856 |
+
no_egg = any(t in restrictions_text for t in ("egg", "anda", "انڈا"))
|
| 857 |
+
gluten_free = any(t in restrictions_text for t in ("gluten", "wheat", "roti", "گندم"))
|
| 858 |
+
vegetarian = "vegetarian" in prefs or "vegan" in prefs or "vegetarian" in restrictions_text
|
| 859 |
+
|
| 860 |
+
days = (_base_week_ur if urdu else _base_week_en)(
|
| 861 |
+
vegetarian, dairy_free, no_egg, gluten_free
|
| 862 |
+
)
|
| 863 |
+
|
| 864 |
+
tips = (
|
| 865 |
+
[
|
| 866 |
+
"دن میں 6 سے 8 گلاس پانی پیئیں، اگر ڈاکٹر نے پانی کم کرنے کو نہ کہا ہو۔",
|
| 867 |
+
"کھانا روز ایک ہی وقت پر کھائیں اور ناشتہ نہ چھوڑیں۔",
|
| 868 |
+
"ہر کھانے کے ساتھ تھوڑی سبزی یا سلاد ضرور رکھیں۔",
|
| 869 |
+
"تلی ہوئی چیزیں اور بازار کا کھانا ہفتے میں ایک بار سے زیادہ نہ کھائیں۔",
|
| 870 |
+
]
|
| 871 |
+
if urdu
|
| 872 |
+
else [
|
| 873 |
+
"Drink 6 to 8 glasses of water a day, unless your doctor has told you to drink less.",
|
| 874 |
+
"Eat at the same times every day and do not skip breakfast.",
|
| 875 |
+
"Keep some sabzi or salad with every meal.",
|
| 876 |
+
"Keep fried food and bazaar food to no more than once a week.",
|
| 877 |
+
]
|
| 878 |
+
)
|
| 879 |
|
| 880 |
if _has_any(context, HYPERTENSION_TERMS):
|
| 881 |
+
tips = _merge_tips(tips, _BP_TIPS_UR if urdu else _BP_TIPS_EN)
|
| 882 |
+
if _has_any(context, DIABETES_TERMS):
|
| 883 |
+
tips = _merge_tips(tips, _SUGAR_TIPS_UR if urdu else _SUGAR_TIPS_EN)
|
| 884 |
+
if _has_any(context, KIDNEY_TERMS):
|
| 885 |
+
tips = _merge_tips(tips, _KIDNEY_TIPS_UR if urdu else _KIDNEY_TIPS_EN)
|
| 886 |
+
if condition and condition.lower() != "general wellness":
|
| 887 |
+
tips = _merge_tips(tips, [_DOCTOR_TIP_UR if urdu else _DOCTOR_TIP_EN])
|
| 888 |
+
|
| 889 |
+
# The summary names the foods the plan is built from, so it has to drop the
|
| 890 |
+
# ones this person cannot eat — otherwise it advertises anda and dahi to
|
| 891 |
+
# someone who told us they avoid both.
|
| 892 |
+
if urdu:
|
| 893 |
+
title = "سات دن کا آسان پاکستانی کھانے کا پلان"
|
| 894 |
+
foods = []
|
| 895 |
+
if not no_egg:
|
| 896 |
+
foods.append("انڈا")
|
| 897 |
+
if not gluten_free:
|
| 898 |
+
foods.append("روٹی")
|
| 899 |
+
foods += ["دال", "چاول", "سبزی"]
|
| 900 |
+
if not dairy_free:
|
| 901 |
+
foods.append("دہی")
|
| 902 |
+
foods.append("پھل")
|
| 903 |
+
summary = (
|
| 904 |
+
"روزمرہ کے عام گھریلو کھانوں پر مبنی سات دن کا سادہ پلان — "
|
| 905 |
+
+ "، ".join(foods)
|
| 906 |
+
+ "۔ سب کچھ عام بازار سے مل جاتا ہے۔"
|
| 907 |
+
)
|
| 908 |
+
else:
|
| 909 |
+
title = "Simple 7-Day Pakistani Meal Plan"
|
| 910 |
+
foods = []
|
| 911 |
+
if not no_egg:
|
| 912 |
+
foods.append("anda")
|
| 913 |
+
foods.append("makai ki roti" if gluten_free else "roti")
|
| 914 |
+
foods += ["daal", "chawal", "sabzi"]
|
| 915 |
+
if not dairy_free:
|
| 916 |
+
foods.append("dahi")
|
| 917 |
+
foods.append("fruit")
|
| 918 |
+
summary = (
|
| 919 |
+
"A simple 7-day plan made from everyday home food — "
|
| 920 |
+
+ ", ".join(foods[:-1])
|
| 921 |
+
+ f" and {foods[-1]}. Everything is available in any local market."
|
| 922 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 923 |
|
| 924 |
return {
|
| 925 |
+
"title": title,
|
| 926 |
+
"summary": summary,
|
| 927 |
+
"plan": days,
|
| 928 |
+
"tips": tips[:6],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 929 |
}
|
|
@@ -1,58 +1,264 @@
|
|
| 1 |
-
"""OpenRouter chat-completions client.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
|
|
|
|
|
|
| 5 |
import httpx
|
|
|
|
| 6 |
from app.config import get_settings
|
| 7 |
|
|
|
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
*,
|
| 12 |
-
system_prompt: str | None = None,
|
| 13 |
temperature: float = 0.2,
|
| 14 |
max_tokens: int = 2048,
|
|
|
|
| 15 |
) -> str:
|
| 16 |
-
"""Send a
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
settings = get_settings()
|
| 18 |
if not settings.openrouter_api_key:
|
| 19 |
-
raise
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
if system_prompt:
|
| 23 |
messages.append({"role": "system", "content": system_prompt})
|
| 24 |
messages.append({"role": "user", "content": prompt})
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
"HTTP-Referer": settings.openrouter_site_url,
|
| 32 |
-
"X-Title": settings.openrouter_app_name,
|
| 33 |
-
},
|
| 34 |
-
json={
|
| 35 |
-
"model": settings.openrouter_model,
|
| 36 |
-
"messages": messages,
|
| 37 |
-
"temperature": temperature,
|
| 38 |
-
"max_tokens": max_tokens,
|
| 39 |
-
},
|
| 40 |
-
timeout=settings.openrouter_timeout_seconds,
|
| 41 |
)
|
| 42 |
-
try:
|
| 43 |
-
response.raise_for_status()
|
| 44 |
-
except httpx.HTTPStatusError as exc:
|
| 45 |
-
detail = response.text[:500]
|
| 46 |
-
raise RuntimeError(
|
| 47 |
-
f"OpenRouter request failed with HTTP {response.status_code}: {detail}"
|
| 48 |
-
) from exc
|
| 49 |
-
|
| 50 |
-
data = response.json()
|
| 51 |
-
choices = data.get("choices") or []
|
| 52 |
-
if not choices:
|
| 53 |
-
raise RuntimeError("OpenRouter returned no choices.")
|
| 54 |
-
|
| 55 |
-
content = choices[0].get("message", {}).get("content", "")
|
| 56 |
-
if not content:
|
| 57 |
-
raise RuntimeError("OpenRouter returned an empty response.")
|
| 58 |
-
return content
|
|
|
|
| 1 |
+
"""OpenRouter chat-completions client.
|
| 2 |
+
|
| 3 |
+
The app runs on OpenRouter's free model tier, which is rate limited per day and
|
| 4 |
+
occasionally retires model IDs. A single failed call used to take down both the
|
| 5 |
+
health chatbot and the diet planner, so this client:
|
| 6 |
+
|
| 7 |
+
* sends a list of models so OpenRouter can fall back automatically,
|
| 8 |
+
* retries 429 / 5xx responses with backoff, honouring ``Retry-After``,
|
| 9 |
+
* raises :class:`OpenRouterError` with a message that is safe to show a user.
|
| 10 |
+
"""
|
| 11 |
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
+
import logging
|
| 15 |
+
import time
|
| 16 |
+
|
| 17 |
import httpx
|
| 18 |
+
|
| 19 |
from app.config import get_settings
|
| 20 |
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
|
| 23 |
+
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
| 24 |
+
|
| 25 |
+
MAX_ATTEMPTS = 3
|
| 26 |
+
BACKOFF_SECONDS = (1.0, 3.0)
|
| 27 |
+
RETRYABLE_STATUS = {408, 409, 429, 500, 502, 503, 504}
|
| 28 |
+
|
| 29 |
+
# OpenRouter rejects the request outright with
|
| 30 |
+
# "'models' array must have 3 items or fewer." past this, so the chain is
|
| 31 |
+
# truncated rather than allowed to fail every call.
|
| 32 |
+
MAX_MODELS = 3
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class OpenRouterError(RuntimeError):
|
| 36 |
+
"""Raised when OpenRouter cannot produce a completion.
|
| 37 |
+
|
| 38 |
+
``user_message`` is phrased for display in the UI; ``str(exc)`` keeps the
|
| 39 |
+
technical detail for the logs.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
def __init__(self, detail: str, user_message: str, *, status_code: int | None = None):
|
| 43 |
+
super().__init__(detail)
|
| 44 |
+
self.user_message = user_message
|
| 45 |
+
self.status_code = status_code
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _model_candidates() -> list[str]:
|
| 49 |
+
"""Primary model first, then any configured fallbacks, de-duplicated."""
|
| 50 |
+
settings = get_settings()
|
| 51 |
+
candidates = [settings.openrouter_model.strip()]
|
| 52 |
+
candidates += [m.strip() for m in settings.openrouter_fallback_models.split(",")]
|
| 53 |
+
|
| 54 |
+
ordered: list[str] = []
|
| 55 |
+
for model in candidates:
|
| 56 |
+
if model and model not in ordered:
|
| 57 |
+
ordered.append(model)
|
| 58 |
+
return ordered[:MAX_MODELS]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _retry_after_seconds(response: httpx.Response, attempt: int) -> float:
|
| 62 |
+
header = response.headers.get("Retry-After")
|
| 63 |
+
if header:
|
| 64 |
+
try:
|
| 65 |
+
# Cap it so a long provider cooldown never stalls a web request.
|
| 66 |
+
return min(float(header), 10.0)
|
| 67 |
+
except ValueError:
|
| 68 |
+
pass
|
| 69 |
+
return BACKOFF_SECONDS[min(attempt, len(BACKOFF_SECONDS) - 1)]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _extract_content(data: dict) -> str:
|
| 73 |
+
choices = data.get("choices") or []
|
| 74 |
+
if not choices:
|
| 75 |
+
return ""
|
| 76 |
+
|
| 77 |
+
message = choices[0].get("message") or {}
|
| 78 |
+
content = message.get("content")
|
| 79 |
+
|
| 80 |
+
# Some providers return the text as a list of content parts.
|
| 81 |
+
if isinstance(content, list):
|
| 82 |
+
content = "".join(
|
| 83 |
+
part.get("text", "")
|
| 84 |
+
for part in content
|
| 85 |
+
if isinstance(part, dict)
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
if isinstance(content, str) and content.strip():
|
| 89 |
+
return content
|
| 90 |
+
|
| 91 |
+
# Reasoning models sometimes leave `content` empty and put the answer in
|
| 92 |
+
# `reasoning`. Better a reasoning dump than a dead chatbot.
|
| 93 |
+
reasoning = message.get("reasoning")
|
| 94 |
+
if isinstance(reasoning, str) and reasoning.strip():
|
| 95 |
+
return reasoning
|
| 96 |
+
|
| 97 |
+
return ""
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def complete_chat(
|
| 101 |
+
messages: list[dict],
|
| 102 |
*,
|
|
|
|
| 103 |
temperature: float = 0.2,
|
| 104 |
max_tokens: int = 2048,
|
| 105 |
+
reasoning: dict | None = None,
|
| 106 |
) -> str:
|
| 107 |
+
"""Send a list of role/content messages and return the assistant text.
|
| 108 |
+
|
| 109 |
+
`reasoning` maps to OpenRouter's reasoning controls. The free tier routes to
|
| 110 |
+
reasoning models (a live run landed on nemotron, which spent 2087 of 3000
|
| 111 |
+
tokens thinking and truncated the answer), so callers that need a long,
|
| 112 |
+
complete answer should turn reasoning down and raise `max_tokens`.
|
| 113 |
+
"""
|
| 114 |
settings = get_settings()
|
| 115 |
if not settings.openrouter_api_key:
|
| 116 |
+
raise OpenRouterError(
|
| 117 |
+
"OPENROUTER_API_KEY is not configured.",
|
| 118 |
+
"The AI service is not configured yet. Please add an OpenRouter API key.",
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
models = _model_candidates()
|
| 122 |
+
payload: dict = {
|
| 123 |
+
"model": models[0],
|
| 124 |
+
"messages": messages,
|
| 125 |
+
"temperature": temperature,
|
| 126 |
+
"max_tokens": max_tokens,
|
| 127 |
+
}
|
| 128 |
+
if len(models) > 1:
|
| 129 |
+
# OpenRouter tries each model in order when the earlier ones fail.
|
| 130 |
+
payload["models"] = models
|
| 131 |
+
if reasoning:
|
| 132 |
+
payload["reasoning"] = reasoning
|
| 133 |
+
|
| 134 |
+
headers = {
|
| 135 |
+
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
| 136 |
+
"Content-Type": "application/json",
|
| 137 |
+
"HTTP-Referer": settings.openrouter_site_url,
|
| 138 |
+
"X-Title": settings.openrouter_app_name,
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
last_error: OpenRouterError | None = None
|
| 142 |
+
|
| 143 |
+
for attempt in range(MAX_ATTEMPTS):
|
| 144 |
+
try:
|
| 145 |
+
response = httpx.post(
|
| 146 |
+
OPENROUTER_URL,
|
| 147 |
+
headers=headers,
|
| 148 |
+
json=payload,
|
| 149 |
+
timeout=settings.openrouter_timeout_seconds,
|
| 150 |
+
)
|
| 151 |
+
except httpx.RequestError as exc:
|
| 152 |
+
last_error = OpenRouterError(
|
| 153 |
+
f"OpenRouter request error: {exc}",
|
| 154 |
+
"Could not reach the AI service. Please check your connection and try again.",
|
| 155 |
+
)
|
| 156 |
+
logger.warning("OpenRouter attempt %s/%s failed: %s", attempt + 1, MAX_ATTEMPTS, exc)
|
| 157 |
+
if attempt < MAX_ATTEMPTS - 1:
|
| 158 |
+
time.sleep(BACKOFF_SECONDS[min(attempt, len(BACKOFF_SECONDS) - 1)])
|
| 159 |
+
continue
|
| 160 |
+
|
| 161 |
+
if response.status_code in RETRYABLE_STATUS:
|
| 162 |
+
detail = response.text[:500]
|
| 163 |
+
if response.status_code == 429:
|
| 164 |
+
user_message = (
|
| 165 |
+
"The free AI service has hit its usage limit for now. "
|
| 166 |
+
"Please wait a minute and try again."
|
| 167 |
+
)
|
| 168 |
+
else:
|
| 169 |
+
user_message = "The AI service is busy right now. Please try again in a moment."
|
| 170 |
+
last_error = OpenRouterError(
|
| 171 |
+
f"OpenRouter HTTP {response.status_code}: {detail}",
|
| 172 |
+
user_message,
|
| 173 |
+
status_code=response.status_code,
|
| 174 |
+
)
|
| 175 |
+
logger.warning(
|
| 176 |
+
"OpenRouter attempt %s/%s got HTTP %s: %s",
|
| 177 |
+
attempt + 1, MAX_ATTEMPTS, response.status_code, detail,
|
| 178 |
+
)
|
| 179 |
+
if attempt < MAX_ATTEMPTS - 1:
|
| 180 |
+
time.sleep(_retry_after_seconds(response, attempt))
|
| 181 |
+
continue
|
| 182 |
+
|
| 183 |
+
if response.status_code >= 400:
|
| 184 |
+
detail = response.text[:500]
|
| 185 |
+
logger.error("OpenRouter HTTP %s: %s", response.status_code, detail)
|
| 186 |
+
if response.status_code in (401, 403):
|
| 187 |
+
user_message = "The AI service rejected the configured API key."
|
| 188 |
+
elif response.status_code == 404:
|
| 189 |
+
user_message = (
|
| 190 |
+
"The configured AI model is unavailable. "
|
| 191 |
+
"Please update OPENROUTER_MODEL."
|
| 192 |
+
)
|
| 193 |
+
else:
|
| 194 |
+
user_message = "The AI service returned an error. Please try again."
|
| 195 |
+
raise OpenRouterError(
|
| 196 |
+
f"OpenRouter HTTP {response.status_code}: {detail}",
|
| 197 |
+
user_message,
|
| 198 |
+
status_code=response.status_code,
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
data = response.json()
|
| 202 |
+
|
| 203 |
+
# OpenRouter can return HTTP 200 with an error body.
|
| 204 |
+
if isinstance(data.get("error"), dict):
|
| 205 |
+
detail = str(data["error"].get("message", "unknown error"))
|
| 206 |
+
last_error = OpenRouterError(
|
| 207 |
+
f"OpenRouter error payload: {detail}",
|
| 208 |
+
"The AI service is unavailable right now. Please try again in a moment.",
|
| 209 |
+
)
|
| 210 |
+
logger.warning("OpenRouter returned an error payload: %s", detail)
|
| 211 |
+
if attempt < MAX_ATTEMPTS - 1:
|
| 212 |
+
time.sleep(BACKOFF_SECONDS[min(attempt, len(BACKOFF_SECONDS) - 1)])
|
| 213 |
+
continue
|
| 214 |
+
|
| 215 |
+
content = _extract_content(data)
|
| 216 |
+
if content:
|
| 217 |
+
choice = (data.get("choices") or [{}])[0]
|
| 218 |
+
if choice.get("finish_reason") == "length":
|
| 219 |
+
# Not fatal — the caller may still salvage it — but it is the
|
| 220 |
+
# usual reason a long structured answer comes back unparseable.
|
| 221 |
+
usage = data.get("usage") or {}
|
| 222 |
+
logger.warning(
|
| 223 |
+
"OpenRouter response hit the token limit (model=%s, completion=%s, "
|
| 224 |
+
"reasoning=%s). The answer is truncated.",
|
| 225 |
+
data.get("model"),
|
| 226 |
+
usage.get("completion_tokens"),
|
| 227 |
+
(usage.get("completion_tokens_details") or {}).get("reasoning_tokens"),
|
| 228 |
+
)
|
| 229 |
+
return content
|
| 230 |
+
|
| 231 |
+
last_error = OpenRouterError(
|
| 232 |
+
"OpenRouter returned an empty response.",
|
| 233 |
+
"The AI service returned an empty answer. Please try again.",
|
| 234 |
+
)
|
| 235 |
+
logger.warning("OpenRouter returned an empty completion (model=%s)", data.get("model"))
|
| 236 |
+
if attempt < MAX_ATTEMPTS - 1:
|
| 237 |
+
time.sleep(BACKOFF_SECONDS[min(attempt, len(BACKOFF_SECONDS) - 1)])
|
| 238 |
|
| 239 |
+
raise last_error or OpenRouterError(
|
| 240 |
+
"OpenRouter request failed.",
|
| 241 |
+
"The AI service is unavailable right now. Please try again in a moment.",
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def complete_text(
|
| 246 |
+
prompt: str,
|
| 247 |
+
*,
|
| 248 |
+
system_prompt: str | None = None,
|
| 249 |
+
temperature: float = 0.2,
|
| 250 |
+
max_tokens: int = 2048,
|
| 251 |
+
reasoning: dict | None = None,
|
| 252 |
+
) -> str:
|
| 253 |
+
"""Send a single prompt to OpenRouter and return the assistant text."""
|
| 254 |
+
messages: list[dict] = []
|
| 255 |
if system_prompt:
|
| 256 |
messages.append({"role": "system", "content": system_prompt})
|
| 257 |
messages.append({"role": "user", "content": prompt})
|
| 258 |
|
| 259 |
+
return complete_chat(
|
| 260 |
+
messages,
|
| 261 |
+
temperature=temperature,
|
| 262 |
+
max_tokens=max_tokens,
|
| 263 |
+
reasoning=reasoning,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|