zohaibcodez Claude Opus 5 (1M context) commited on
Commit
1f438ae
·
1 Parent(s): 99ca25e

fix: repair health chat and rewrite the diet planner for Pakistani home food

Browse files

Chat 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 CHANGED
@@ -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
app/models/schemas.py CHANGED
@@ -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):
app/routers/chat.py CHANGED
@@ -1,6 +1,8 @@
1
  """Health chatbot endpoints — FYP requirement."""
2
 
3
- from __future__ import annotations
 
 
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
- # Create or use existing session
 
 
 
 
 
 
 
 
 
 
 
26
  session_id = req.session_id
27
- if not session_id:
28
- session = create_chat_session(user_id, title=req.message[:50])
29
- session_id = session["id"]
30
- elif not user_owns_chat_session(session_id, user_id):
31
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat session not found.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
- # Get conversation history for context
34
- history = get_chat_messages(session_id, user_id)
 
 
35
 
36
- # Save user message
37
- insert_chat_message(session_id, "user", req.message)
 
 
 
 
 
 
 
 
 
 
38
 
39
- # Get AI response
40
  result = chat_with_health_bot(
41
- message=req.message,
42
  conversation_history=history,
43
  language=req.language,
44
  )
45
 
46
- # Save assistant response
47
- insert_chat_message(session_id, "assistant", result["reply"])
 
 
 
 
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
- sessions = get_chat_sessions(user_id)
 
 
 
 
 
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
- messages = get_chat_messages(session_id, user_id)
78
- return messages
 
 
 
 
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 []
app/routers/diet.py CHANGED
@@ -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
app/services/chatbot_service.py CHANGED
@@ -1,17 +1,23 @@
1
- """Health chatbot service powered by OpenRouter GLM 4.5 Air.
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
- from app.services.openrouter_client import complete_text
 
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
- - Provide evidence-based information when possible.
28
- - Keep responses concise but thorough (2-4 paragraphs max).
 
 
 
 
 
29
 
30
- Respond in a structured, helpful manner. If you recommend a doctor type, mention it clearly.
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 home_remedies.
 
63
  """
64
- system_prompt = SYSTEM_PROMPT_UR if language == "ur" else SYSTEM_PROMPT_EN
65
-
66
- # Build conversation context
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
- messages.append(f"User: {message}")
 
 
75
 
76
- lang_instruction = ""
77
- if language == "ur":
78
- lang_instruction = "\n\nIMPORTANT: Respond entirely in Urdu (اردو)."
 
 
79
 
80
- full_prompt = "\n\n".join(messages) + lang_instruction + """
 
 
 
81
 
82
- Respond with helpful medical guidance. Also include at the end of your response (in a new line):
83
- DOCTOR_TYPE: <specialist type or "none">
84
- HOME_REMEDIES: <comma-separated list of remedies or "none">
85
- """
86
 
87
  try:
88
- response_text = complete_text(full_prompt, temperature=0.35, max_tokens=1200)
89
- return _parse_chat_response(response_text)
90
- except Exception as e:
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
- if stripped.upper().startswith("DOCTOR_TYPE:"):
115
- val = stripped.split(":", 1)[1].strip()
116
- doctor_type = val if val.lower() != "none" else None
117
- elif stripped.upper().startswith("HOME_REMEDIES:"):
118
- val = stripped.split(":", 1)[1].strip()
119
- if val.lower() != "none":
 
 
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": "\n".join(reply_lines).strip(),
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
  }
app/services/diet_service.py CHANGED
@@ -1,7 +1,11 @@
1
  """Personalized diet plan generator powered by OpenRouter.
2
 
3
- The model creates the menu, while this service adds condition-specific
4
- guardrails and validates that the response is complete enough to show.
 
 
 
 
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 weekly diet plan."""
30
  restrictions = restrictions or []
31
  condition_text = condition or "general wellness"
32
  restrictions_text = ", ".join(restrictions) if restrictions else "none"
33
- lang_instruction = "Respond entirely in Urdu." if language == "ur" else ""
 
 
 
 
 
 
 
 
34
 
35
- prompt = f"""You are a nutrition assistant creating an educational 7-day meal plan.
 
36
 
37
  PATIENT CONTEXT:
38
  - Medical Condition: {condition_text}
39
  - Dietary Preference: {dietary_preferences}
40
- - Restrictions: {restrictions_text}
41
- - Health Goals: {goals}
42
 
43
  {lang_instruction}
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  MEDICAL AND SAFETY RULES:
46
  {_condition_rules(condition_text, goals)}
47
 
48
- RESTRICTION RULES:
49
  {_restriction_rules(restrictions)}
50
 
51
  QUALITY RULES:
52
  - Include exactly 7 days, Day 1 through Day 7.
53
- - Use realistic meals and portions, not extreme dieting.
54
  - Calorie values must be plausible estimates per meal.
55
- - Do not include foods that violate allergies or restrictions.
56
- - Tips must include one professional-safety note when a medical condition is present.
57
- - Do not claim the plan diagnoses, treats, cures, or replaces clinician care.
58
 
59
- Respond ONLY in this JSON format, with no markdown or extra text:
60
  {{
61
- "title": "<diet plan title>",
62
- "summary": "<2-3 sentence summary of the diet approach>",
63
  "plan": [
64
  {{
65
  "day": "Day 1",
66
- "breakfast": {{"name": "<meal>", "description": "<brief description>", "calories": <number>, "nutrients": "<key nutrients>"}},
67
- "lunch": {{"name": "<meal>", "description": "<brief description>", "calories": <number>, "nutrients": "<key nutrients>"}},
68
- "dinner": {{"name": "<meal>", "description": "<brief description>", "calories": <number>, "nutrients": "<key nutrients>"}},
69
- "snacks": [{{"name": "<snack>", "description": "<brief>", "calories": <number>, "nutrients": "<key nutrients>"}}]
70
  }}
71
  ],
72
- "tips": ["<health tip 1>", "<health tip 2>", "<health tip 3>"]
73
  }}
74
  """
75
 
76
  try:
77
- response_text = complete_text(prompt, temperature=0.25, max_tokens=2600)
 
 
 
 
 
 
 
 
78
  parsed = _parse_diet_response(response_text)
79
- return _normalize_diet_plan(parsed, condition_text, dietary_preferences, restrictions, goals)
80
- except Exception as exc:
81
- logger.error("Diet plan generation failed: %s", exc)
82
- return _fallback_diet_plan(condition_text, dietary_preferences, restrictions, goals)
 
 
 
 
 
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 whole foods, realistic portions, and culturally adaptable meals.",
94
- "Avoid claiming the plan treats, cures, or replaces care from a clinician.",
95
- "Do not include foods that conflict with stated allergies or restrictions.",
96
- "Include calorie estimates, but avoid extreme daily calorie restriction.",
97
  ]
98
 
99
  if _has_any(context, HYPERTENSION_TERMS):
100
  rules.extend([
101
- "For blood pressure, follow a DASH-style pattern: vegetables, fruits, beans/lentils, whole grains, fish/poultry/soy, nuts/seeds, and unsaturated fats.",
102
- "Keep sodium low: prefer unsalted/no-salt-added foods; avoid processed meats, instant noodles, pickles, salty sauces, regular miso, regular soy sauce, and salty packaged snacks.",
103
- "Mention a sodium target of no more than 2,300 mg/day, and ideally 1,500 mg/day if advised by a clinician.",
104
- "Emphasize potassium-rich foods only with a caution: people with kidney disease or potassium-raising medicines should ask a clinician first.",
 
 
105
  ])
106
  if _has_any(context, DIABETES_TERMS):
107
  rules.extend([
108
- "For diabetes, distribute carbohydrates across meals and pair carbs with protein, fiber, or healthy fat.",
109
- "Avoid sugary drinks and large dessert portions; prefer low-glycemic whole grains, legumes, non-starchy vegetables, lean proteins, and unsweetened foods.",
 
 
 
 
 
110
  ])
111
  if _has_any(context, KIDNEY_TERMS):
112
  rules.extend([
113
- "For kidney disease, do not recommend high-potassium or high-phosphorus patterns unless clinician-approved.",
114
- "Avoid generic advice to increase potassium; recommend renal dietitian review.",
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("Dairy-free means no milk, yogurt, cheese, paneer, butter, cream, whey, casein, or cottage cheese; use fortified unsweetened alternatives if needed.")
126
- if "gluten" in joined:
127
- rules.append("Gluten-free means no wheat, barley, rye, regular bread, regular pasta, or standard wraps; use certified gluten-free grains.")
128
- if "nut" in joined or "peanut" in joined:
129
- rules.append("Nut allergy means no tree nuts, peanuts, nut butters, almond milk, cashews, walnuts, pistachios, or nut-based snacks.")
130
- if "shellfish" in joined or "shrimp" in joined:
131
- rules.append("Shellfish restriction means no shrimp, prawns, crab, lobster, or shellfish sauces.")
132
-
133
- return "\n".join(f"- {rule}" for rule in rules) or "- No extra restriction-specific exclusions."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 Personalized Diet Plan"),
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 add condition-specific guardrails."""
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
- "This educational plan should be reviewed by a qualified clinician or dietitian for your diagnosis, medicines, labs, and allergies.",
205
- ])
 
 
 
 
 
206
 
207
  return {
208
- "title": plan.get("title") or "Your Personalized Diet Plan",
209
- "summary": plan.get("summary") or "A structured 7-day meal plan built around whole foods, balanced portions, and the stated health goals.",
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
- """Return a validated fallback plan when the model is unavailable."""
236
  restrictions = restrictions or []
237
  context = f"{condition} {goals}".lower()
238
  restrictions_text = " ".join(restrictions).lower()
239
- dairy_free = "dairy" in restrictions_text or "lactose" in restrictions_text
240
- vegetarian = "vegetarian" in dietary_preferences.lower() or "vegan" in dietary_preferences.lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
 
242
  if _has_any(context, HYPERTENSION_TERMS):
243
- protein_lunch = "Lentil Quinoa Bowl" if vegetarian else "Grilled Chicken Brown Rice Bowl"
244
- dinner_name = "Bean and Vegetable Stew" if vegetarian else "Herb Baked Fish"
245
- dinner_nutrients = "Fiber, magnesium, plant protein" if vegetarian else "Omega-3, protein, potassium"
246
- dairy_alt = "fortified unsweetened soy milk" if dairy_free else "low-fat yogurt"
247
- return {
248
- "title": "7-Day Low-Sodium DASH-Style Diet Plan",
249
- "summary": "A blood-pressure-focused plan emphasizing vegetables, fruits, legumes, whole grains, lean proteins, unsalted nuts or seeds, and low-sodium preparation. It avoids salty sauces and highly processed foods while keeping dairy optional based on restrictions.",
250
- "plan": [
251
- {
252
- "day": f"Day {i}",
253
- "breakfast": {
254
- "name": "Oat Berry Bowl",
255
- "description": f"Rolled oats with berries, chia seeds, cinnamon, and {dairy_alt}",
256
- "calories": 360,
257
- "nutrients": "Fiber, magnesium, potassium",
258
- },
259
- "lunch": {
260
- "name": protein_lunch,
261
- "description": "Whole grains, vegetables, herbs, olive oil, and no-salt-added protein",
262
- "calories": 470,
263
- "nutrients": "Protein, fiber, unsaturated fats",
264
- },
265
- "dinner": {
266
- "name": dinner_name,
267
- "description": "Served with sweet potato and steamed greens; seasoned with lemon, garlic, and herbs instead of salt",
268
- "calories": 510,
269
- "nutrients": dinner_nutrients,
270
- },
271
- "snacks": [
272
- {
273
- "name": "Fruit with Unsalted Seeds",
274
- "description": "Apple or orange with unsalted pumpkin or sunflower seeds",
275
- "calories": 180,
276
- "nutrients": "Fiber, minerals",
277
- }
278
- ],
279
- }
280
- for i in range(1, 8)
281
- ],
282
- "tips": [
283
- "Keep sodium under 2,300 mg/day; ask a clinician whether a 1,500 mg/day target is appropriate.",
284
- "Use no-salt-added foods and herbs/spices instead of salty sauces, pickles, processed meats, or packaged snacks.",
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": "General 7-Day Balanced Diet Plan",
292
- "summary": "A 7-day balanced diet plan focusing on whole foods, lean proteins, and plenty of vegetables.",
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
  }
app/services/openrouter_client.py CHANGED
@@ -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
- def complete_text(
10
- prompt: str,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  *,
12
- system_prompt: str | None = None,
13
  temperature: float = 0.2,
14
  max_tokens: int = 2048,
 
15
  ) -> str:
16
- """Send a prompt to OpenRouter and return the assistant text."""
 
 
 
 
 
 
17
  settings = get_settings()
18
  if not settings.openrouter_api_key:
19
- raise RuntimeError("OPENROUTER_API_KEY is not configured.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
- messages = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  if system_prompt:
23
  messages.append({"role": "system", "content": system_prompt})
24
  messages.append({"role": "user", "content": prompt})
25
 
26
- response = httpx.post(
27
- "https://openrouter.ai/api/v1/chat/completions",
28
- headers={
29
- "Authorization": f"Bearer {settings.openrouter_api_key}",
30
- "Content-Type": "application/json",
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
  )