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 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-04 01:27:03 +02:00
parent 4c48a5cf94
commit 4cbaa529f0
5 changed files with 86 additions and 34 deletions

View file

@ -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(

View file

@ -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 = []

View file

@ -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}")

View file

@ -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:

View file

@ -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 }) {
</div>
)}
{messages.map((m, i) => (
<div key={i} style={{
alignSelf: m.role === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '85%',
background: m.role === 'user' ? 'var(--primary)' : 'var(--bg)',
color: m.role === 'user' ? 'white' : 'var(--text)',
padding: '8px 12px',
borderRadius: m.role === 'user' ? '12px 12px 2px 12px' : '12px 12px 12px 2px',
fontSize: '0.85rem',
lineHeight: 1.55,
border: m.role === 'assistant' ? '1px solid var(--border)' : 'none',
}}>
{m.role === 'assistant'
? <ReactMarkdown components={{
p: ({children}) => <p style={{margin: '0 0 6px'}}>{children}</p>,
ul: ({children}) => <ul style={{margin: '4px 0', paddingLeft: 18}}>{children}</ul>,
ol: ({children}) => <ol style={{margin: '4px 0', paddingLeft: 18}}>{children}</ol>,
li: ({children}) => <li style={{marginBottom: 2}}>{children}</li>,
strong: ({children}) => <strong style={{fontWeight: 700}}>{children}</strong>,
code: ({children}) => <code style={{background: 'var(--border)', borderRadius: 3, padding: '1px 4px', fontSize: '0.82em'}}>{children}</code>,
}}>{m.content}</ReactMarkdown>
: m.content
}
<div key={i} style={{ alignSelf: m.role === 'user' ? 'flex-end' : 'flex-start', maxWidth: '90%', display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{
background: m.role === 'user' ? 'var(--primary)' : 'var(--bg)',
color: m.role === 'user' ? 'white' : 'var(--text)',
padding: '8px 12px',
borderRadius: m.role === 'user' ? '12px 12px 2px 12px' : '12px 12px 12px 2px',
fontSize: '0.85rem',
lineHeight: 1.55,
border: m.role === 'assistant' ? '1px solid var(--border)' : 'none',
}}>
{m.role === 'assistant'
? <ReactMarkdown components={{
p: ({children}) => <p style={{margin: '0 0 6px'}}>{children}</p>,
ul: ({children}) => <ul style={{margin: '4px 0', paddingLeft: 18}}>{children}</ul>,
ol: ({children}) => <ol style={{margin: '4px 0', paddingLeft: 18}}>{children}</ol>,
li: ({children}) => <li style={{marginBottom: 2}}>{children}</li>,
strong: ({children}) => <strong style={{fontWeight: 700}}>{children}</strong>,
code: ({children}) => <code style={{background: 'var(--border)', borderRadius: 3, padding: '1px 4px', fontSize: '0.82em'}}>{children}</code>,
}}>{m.content}</ReactMarkdown>
: m.content
}
</div>
{/* Clickable follow-up suggestion chips — only on last assistant message */}
{m.role === 'assistant' && m.suggestions?.length > 0 && i === messages.length - 1 && !loading && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{m.suggestions.map((s, si) => (
<button
key={si}
onClick={() => { setInput(s); setTimeout(() => inputRef.current?.focus(), 50) }}
style={{
textAlign: 'left', background: 'var(--card-bg)', border: '1px solid var(--primary)',
borderRadius: 8, padding: '5px 10px', fontSize: '0.78rem', cursor: 'pointer',
color: 'var(--primary)', lineHeight: 1.4,
transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--primary-light, rgba(99,102,241,0.08))'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--card-bg)'}
>
{s}
</button>
))}
</div>
)}
</div>
))}
{loading && (