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()
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup
|
# Startup — all workers run DDL/seeds (idempotent), only one runs scheduler/backfill
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
setup_pgvector()
|
setup_pgvector()
|
||||||
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
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)
|
os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True)
|
||||||
seed_admin()
|
seed_admin()
|
||||||
seed_default_models()
|
seed_default_models()
|
||||||
backfill_embeddings()
|
# Scheduler and backfill must only run in one worker to avoid duplicate jobs / race conditions
|
||||||
start_scheduler()
|
is_primary = _acquire_singleton_lock()
|
||||||
|
if is_primary:
|
||||||
|
backfill_embeddings()
|
||||||
|
start_scheduler()
|
||||||
yield
|
yield
|
||||||
# Shutdown
|
# Shutdown
|
||||||
stop_scheduler()
|
if is_primary:
|
||||||
|
stop_scheduler()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ def search_litellm_models(
|
||||||
return {"models": models, "source": base}
|
return {"models": models, "source": base}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"LiteLLM model search failed: {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:
|
try:
|
||||||
import litellm
|
import litellm
|
||||||
|
|
@ -304,7 +304,7 @@ def search_tts_voices(
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"ElevenLabs voice discovery failed: {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":
|
elif provider == "polly":
|
||||||
access_key = api_key or settings.AWS_ACCESS_KEY_ID
|
access_key = api_key or settings.AWS_ACCESS_KEY_ID
|
||||||
|
|
@ -337,7 +337,7 @@ def search_tts_voices(
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"AWS Polly voice discovery failed: {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":
|
elif provider == "openai":
|
||||||
voices = []
|
voices = []
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,12 @@ def _build_system_prompt(question: Question, similar: list[Question]) -> str:
|
||||||
prompt += (
|
prompt += (
|
||||||
"\nAnswer the student's question directly. Explain the correct answer, "
|
"\nAnswer the student's question directly. Explain the correct answer, "
|
||||||
"the underlying concept, and why wrong options are incorrect if relevant. "
|
"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
|
return prompt
|
||||||
|
|
||||||
|
|
@ -177,8 +182,18 @@ async def chat(
|
||||||
if settings.LITELLM_API_BASE:
|
if settings.LITELLM_API_BASE:
|
||||||
kwargs["api_base"] = settings.LITELLM_API_BASE
|
kwargs["api_base"] = settings.LITELLM_API_BASE
|
||||||
response = await litellm.acompletion(**kwargs)
|
response = await litellm.acompletion(**kwargs)
|
||||||
reply = response.choices[0].message.content.strip()
|
raw = response.choices[0].message.content.strip()
|
||||||
return {"reply": reply}
|
|
||||||
|
# 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:
|
except Exception as e:
|
||||||
import logging
|
import logging
|
||||||
logging.getLogger(__name__).error(f"TeachChat error for user {current_user.id} model {model_id}: {e}")
|
logging.getLogger(__name__).error(f"TeachChat error for user {current_user.id} model {model_id}: {e}")
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ services:
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
|
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
environment:
|
environment:
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import api from '../api/client'
|
||||||
*/
|
*/
|
||||||
export default function TeachChat({ question }) {
|
export default function TeachChat({ question }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [messages, setMessages] = useState([]) // {role, content}
|
const [messages, setMessages] = useState([]) // {role, content, suggestions?}
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [noModel, setNoModel] = useState(false)
|
const [noModel, setNoModel] = useState(false)
|
||||||
|
|
@ -65,7 +65,7 @@ export default function TeachChat({ question }) {
|
||||||
messages: next,
|
messages: next,
|
||||||
model_id: selectedModelId || null,
|
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) {
|
} catch (err) {
|
||||||
const detail = err.response?.data?.detail || 'AI error'
|
const detail = err.response?.data?.detail || 'AI error'
|
||||||
if (err.response?.status === 503) setNoModel(true)
|
if (err.response?.status === 503) setNoModel(true)
|
||||||
|
|
@ -187,28 +187,49 @@ export default function TeachChat({ question }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.map((m, i) => (
|
{messages.map((m, i) => (
|
||||||
<div key={i} style={{
|
<div key={i} style={{ alignSelf: m.role === 'user' ? 'flex-end' : 'flex-start', maxWidth: '90%', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
alignSelf: m.role === 'user' ? 'flex-end' : 'flex-start',
|
<div style={{
|
||||||
maxWidth: '85%',
|
background: m.role === 'user' ? 'var(--primary)' : 'var(--bg)',
|
||||||
background: m.role === 'user' ? 'var(--primary)' : 'var(--bg)',
|
color: m.role === 'user' ? 'white' : 'var(--text)',
|
||||||
color: m.role === 'user' ? 'white' : 'var(--text)',
|
padding: '8px 12px',
|
||||||
padding: '8px 12px',
|
borderRadius: m.role === 'user' ? '12px 12px 2px 12px' : '12px 12px 12px 2px',
|
||||||
borderRadius: m.role === 'user' ? '12px 12px 2px 12px' : '12px 12px 12px 2px',
|
fontSize: '0.85rem',
|
||||||
fontSize: '0.85rem',
|
lineHeight: 1.55,
|
||||||
lineHeight: 1.55,
|
border: m.role === 'assistant' ? '1px solid var(--border)' : 'none',
|
||||||
border: m.role === 'assistant' ? '1px solid var(--border)' : 'none',
|
}}>
|
||||||
}}>
|
{m.role === 'assistant'
|
||||||
{m.role === 'assistant'
|
? <ReactMarkdown components={{
|
||||||
? <ReactMarkdown components={{
|
p: ({children}) => <p style={{margin: '0 0 6px'}}>{children}</p>,
|
||||||
p: ({children}) => <p style={{margin: '0 0 6px'}}>{children}</p>,
|
ul: ({children}) => <ul style={{margin: '4px 0', paddingLeft: 18}}>{children}</ul>,
|
||||||
ul: ({children}) => <ul style={{margin: '4px 0', paddingLeft: 18}}>{children}</ul>,
|
ol: ({children}) => <ol style={{margin: '4px 0', paddingLeft: 18}}>{children}</ol>,
|
||||||
ol: ({children}) => <ol style={{margin: '4px 0', paddingLeft: 18}}>{children}</ol>,
|
li: ({children}) => <li style={{marginBottom: 2}}>{children}</li>,
|
||||||
li: ({children}) => <li style={{marginBottom: 2}}>{children}</li>,
|
strong: ({children}) => <strong style={{fontWeight: 700}}>{children}</strong>,
|
||||||
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>,
|
||||||
code: ({children}) => <code style={{background: 'var(--border)', borderRadius: 3, padding: '1px 4px', fontSize: '0.82em'}}>{children}</code>,
|
}}>{m.content}</ReactMarkdown>
|
||||||
}}>{m.content}</ReactMarkdown>
|
: m.content
|
||||||
: 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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{loading && (
|
{loading && (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue