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:
parent
4c48a5cf94
commit
4cbaa529f0
5 changed files with 86 additions and 34 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
Loading…
Reference in a new issue