From 4cbaa529f06651ee864f212d2233ea7c1717cebd Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 4 Apr 2026 01:27:03 +0200 Subject: [PATCH] Workers x4, follow-up suggestions, singleton lock, admin error details - docker-compose.yml: --workers 4 on uvicorn - main.py: Redis singleton lock so only one worker runs scheduler + backfill (prevents 4x cron jobs) - teach.py: system prompt requests 3 follow-up suggestions ("> " prefix format) - teach.py: parse and return suggestions[] separately from reply - TeachChat.jsx: render clickable suggestion chips after last assistant message (click fills input) - admin.py: restore full error details for admin endpoints (ElevenLabs, Polly, LiteLLM) Co-Authored-By: Claude Sonnet 4.6 --- backend/app/main.py | 23 +++++++-- backend/app/routers/admin.py | 6 +-- backend/app/routers/teach.py | 21 ++++++-- docker-compose.yml | 1 + frontend/src/components/TeachChat.jsx | 69 +++++++++++++++++---------- 5 files changed, 86 insertions(+), 34 deletions(-) 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) => ( -
- {m.role === 'assistant' - ?

{children}

, - ul: ({children}) =>
    {children}
, - ol: ({children}) =>
    {children}
, - li: ({children}) =>
  • {children}
  • , - strong: ({children}) => {children}, - code: ({children}) => {children}, - }}>{m.content}
    - : m.content - } +
    +
    + {m.role === 'assistant' + ?

    {children}

    , + ul: ({children}) =>
      {children}
    , + ol: ({children}) =>
      {children}
    , + li: ({children}) =>
  • {children}
  • , + strong: ({children}) => {children}, + code: ({children}) => {children}, + }}>{m.content}
    + : m.content + } +
    + {/* Clickable follow-up suggestion chips — only on last assistant message */} + {m.role === 'assistant' && m.suggestions?.length > 0 && i === messages.length - 1 && !loading && ( +
    + {m.suggestions.map((s, si) => ( + + ))} +
    + )}
    ))} {loading && (