diff --git a/backend/app/main.py b/backend/app/main.py index c1ff323..0fb041c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -266,9 +266,20 @@ def backfill_embeddings(): threading.Thread(target=_run, daemon=True).start() +def _acquire_singleton_lock() -> bool: + """Returns True if this worker wins the startup lock for singleton tasks (scheduler, backfill). + Uses Redis SETNX with a 5-minute TTL. Degrades gracefully if Redis is unavailable.""" + try: + import redis as redis_lib + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=2) + return bool(r.set("startup:singleton_lock", "1", nx=True, ex=300)) + except Exception: + return True # Redis unavailable — assume single worker, run everything + + @asynccontextmanager async def lifespan(app: FastAPI): - # Startup + # Startup — all workers run DDL/seeds (idempotent), only one runs scheduler/backfill Base.metadata.create_all(bind=engine) setup_pgvector() os.makedirs(settings.UPLOAD_DIR, exist_ok=True) @@ -276,11 +287,15 @@ async def lifespan(app: FastAPI): os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True) seed_admin() seed_default_models() - backfill_embeddings() - start_scheduler() + # Scheduler and backfill must only run in one worker to avoid duplicate jobs / race conditions + is_primary = _acquire_singleton_lock() + if is_primary: + backfill_embeddings() + start_scheduler() yield # Shutdown - stop_scheduler() + if is_primary: + stop_scheduler() app = FastAPI( diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 82b31a0..fb269b7 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -142,7 +142,7 @@ def search_litellm_models( return {"models": models, "source": base} except Exception as e: log.warning(f"LiteLLM model search failed: {e}") - raise HTTPException(status_code=400, detail="Failed to query models API. Check the API base URL and key.") + raise HTTPException(status_code=400, detail=f"Failed to query models API: {e}") try: import litellm @@ -304,7 +304,7 @@ def search_tts_voices( raise except Exception as e: log.warning(f"ElevenLabs voice discovery failed: {e}") - raise HTTPException(status_code=400, detail="ElevenLabs API error — check your API key and try again.") + raise HTTPException(status_code=400, detail=f"ElevenLabs API error: {e}") elif provider == "polly": access_key = api_key or settings.AWS_ACCESS_KEY_ID @@ -337,7 +337,7 @@ def search_tts_voices( raise except Exception as e: log.warning(f"AWS Polly voice discovery failed: {e}") - raise HTTPException(status_code=400, detail="AWS Polly error — check your credentials and region.") + raise HTTPException(status_code=400, detail=f"AWS Polly error: {e}") elif provider == "openai": voices = [] diff --git a/backend/app/routers/teach.py b/backend/app/routers/teach.py index e9c99de..7d14aa9 100644 --- a/backend/app/routers/teach.py +++ b/backend/app/routers/teach.py @@ -105,7 +105,12 @@ def _build_system_prompt(question: Question, similar: list[Question]) -> str: prompt += ( "\nAnswer the student's question directly. Explain the correct answer, " "the underlying concept, and why wrong options are incorrect if relevant. " - "Do not ask what they want to know — just teach." + "Do not ask what they want to know — just teach.\n\n" + "After your explanation, suggest exactly 3 follow-up questions the student might want to ask next. " + "Put them at the very end, each on its own line starting with '> '. Example:\n" + "> Why is dopamine not the first-line treatment here?\n" + "> What are the other causes of this presentation?\n" + "> How does this differ in neonates vs older children?" ) return prompt @@ -177,8 +182,18 @@ async def chat( if settings.LITELLM_API_BASE: kwargs["api_base"] = settings.LITELLM_API_BASE response = await litellm.acompletion(**kwargs) - reply = response.choices[0].message.content.strip() - return {"reply": reply} + raw = response.choices[0].message.content.strip() + + # Parse out follow-up suggestions (lines starting with "> ") + lines = raw.splitlines() + suggestions = [l[2:].strip() for l in lines if l.startswith("> ")] + reply_lines = [l for l in lines if not l.startswith("> ")] + # Trim trailing blank lines from reply + while reply_lines and not reply_lines[-1].strip(): + reply_lines.pop() + reply = "\n".join(reply_lines).strip() + + return {"reply": reply, "suggestions": suggestions[:3]} except Exception as e: import logging logging.getLogger(__name__).error(f"TeachChat error for user {current_user.id} model {model_id}: {e}") diff --git a/docker-compose.yml b/docker-compose.yml index 29e5baa..c703d48 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,7 @@ services: backend: build: ./backend + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 env_file: - ./backend/.env environment: diff --git a/frontend/src/components/TeachChat.jsx b/frontend/src/components/TeachChat.jsx index a383093..524e07a 100644 --- a/frontend/src/components/TeachChat.jsx +++ b/frontend/src/components/TeachChat.jsx @@ -14,7 +14,7 @@ import api from '../api/client' */ export default function TeachChat({ question }) { const [open, setOpen] = useState(false) - const [messages, setMessages] = useState([]) // {role, content} + const [messages, setMessages] = useState([]) // {role, content, suggestions?} const [input, setInput] = useState('') const [loading, setLoading] = useState(false) const [noModel, setNoModel] = useState(false) @@ -65,7 +65,7 @@ export default function TeachChat({ question }) { messages: next, model_id: selectedModelId || null, }) - setMessages(m => [...m, { role: 'assistant', content: res.data.reply }]) + setMessages(m => [...m, { role: 'assistant', content: res.data.reply, suggestions: res.data.suggestions || [] }]) } catch (err) { const detail = err.response?.data?.detail || 'AI error' if (err.response?.status === 503) setNoModel(true) @@ -187,28 +187,49 @@ export default function TeachChat({ question }) { )} {messages.map((m, i) => ( -
{children}
, - ul: ({children}) =>{children},
- }}>{m.content}{children}
, + ul: ({children}) =>{children},
+ }}>{m.content}