Security: fix SQL injection, add rate limiting, markdown in TeachChat
- teach.py, questions.py: replace f-string SQL with parameterized CAST(:vec AS vector) queries - auth.py: add reusable check_rate_limit() Redis helper - teach.py: rate limit /chat to 30 req/10min per user - tts.py: rate limit /speak to 60 req/hr per user - teach.py: stronger system prompt — no clarifying questions, use markdown, answer directly - TeachChat.jsx: render assistant messages with ReactMarkdown (already in package.json) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
975a31fb01
commit
7f1f14537b
7 changed files with 3278 additions and 22 deletions
|
|
@ -109,14 +109,15 @@ def get_question_bank(
|
|||
from sqlalchemy import text as sa_text
|
||||
emb = generate_embedding(q.strip())
|
||||
if emb:
|
||||
emb_literal = "[" + ",".join(str(x) for x in emb) + "]"
|
||||
rows = db.execute(sa_text(f"""
|
||||
SELECT id, 1 - (embedding <=> '{emb_literal}'::vector) AS sim
|
||||
# Validate all values are finite floats before interpolating into SQL
|
||||
emb_literal = "[" + ",".join(str(float(x)) for x in emb) + "]"
|
||||
rows = db.execute(sa_text("""
|
||||
SELECT id, 1 - (embedding <=> CAST(:vec AS vector)) AS sim
|
||||
FROM questions
|
||||
WHERE embedding IS NOT NULL
|
||||
ORDER BY embedding <=> '{emb_literal}'::vector
|
||||
ORDER BY embedding <=> CAST(:vec AS vector)
|
||||
LIMIT 200
|
||||
""")).fetchall()
|
||||
"""), {"vec": emb_literal}).fetchall()
|
||||
semantic_ids_ordered = [r.id for r in rows if float(r.sim) >= 0.30]
|
||||
|
||||
# ── Keyword filter ─────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from app.database import get_db
|
|||
from app.models.question import Question
|
||||
from app.models.ai_model_config import AIModelConfig
|
||||
from app.models.user import User
|
||||
from app.utils.auth import get_current_user
|
||||
from app.utils.auth import get_current_user, check_rate_limit
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -54,14 +54,15 @@ def _find_similar_questions(db: Session, question: Question, limit: int = 4) ->
|
|||
try:
|
||||
from sqlalchemy import text as sa_text
|
||||
emb = question.embedding
|
||||
emb_literal = "[" + ",".join(str(x) for x in emb) + "]"
|
||||
rows = db.execute(sa_text(f"""
|
||||
SELECT id, 1 - (embedding <=> '{emb_literal}'::vector) AS sim
|
||||
# Validate all values are finite floats before using in SQL
|
||||
emb_literal = "[" + ",".join(str(float(x)) for x in emb) + "]"
|
||||
rows = db.execute(sa_text("""
|
||||
SELECT id, 1 - (embedding <=> CAST(:vec AS vector)) AS sim
|
||||
FROM questions
|
||||
WHERE embedding IS NOT NULL AND id != {question.id}
|
||||
ORDER BY embedding <=> '{emb_literal}'::vector
|
||||
LIMIT {limit}
|
||||
""")).fetchall()
|
||||
WHERE embedding IS NOT NULL AND id != :qid
|
||||
ORDER BY embedding <=> CAST(:vec AS vector)
|
||||
LIMIT :lim
|
||||
"""), {"vec": emb_literal, "qid": int(question.id), "lim": int(limit)}).fetchall()
|
||||
ids = [r.id for r in rows if float(r.sim) >= 0.35]
|
||||
if not ids:
|
||||
return []
|
||||
|
|
@ -77,9 +78,14 @@ def _build_system_prompt(question: Question, similar: list[Question]) -> str:
|
|||
opts = "\n".join(f" {letters[i]}) {opt}" for i, opt in enumerate(question.options))
|
||||
|
||||
prompt = (
|
||||
"You are a medical education tutor helping a student understand the following question. "
|
||||
"Be accurate, educational, and concise. You may reveal and explain the correct answer.\n\n"
|
||||
f"=== Current Question ===\n{question.question_text}\n"
|
||||
"You are a medical education tutor. A student is studying the question below.\n"
|
||||
"Rules:\n"
|
||||
"- Answer the student's question directly. Do NOT ask clarifying questions.\n"
|
||||
"- You may reveal and explain the correct answer and why wrong options are wrong.\n"
|
||||
"- Use markdown formatting: bold key terms, bullet lists for comparisons.\n"
|
||||
"- Keep responses under 200 words unless a detailed explanation is needed.\n"
|
||||
"- Never ask 'what would you like to know?' — just explain.\n\n"
|
||||
f"=== Question ===\n{question.question_text}\n"
|
||||
)
|
||||
if opts:
|
||||
prompt += f"Options:\n{opts}\n"
|
||||
|
|
@ -97,9 +103,9 @@ def _build_system_prompt(question: Question, similar: list[Question]) -> str:
|
|||
prompt += "\n"
|
||||
|
||||
prompt += (
|
||||
"\nAnswer the student's questions about this topic. "
|
||||
"If they ask why an option is wrong, explain the underlying concept. "
|
||||
"Keep responses focused and under 200 words unless a longer explanation is needed."
|
||||
"\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."
|
||||
)
|
||||
return prompt
|
||||
|
||||
|
|
@ -124,6 +130,13 @@ def chat(
|
|||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Send a message to the teach AI with full question context."""
|
||||
# Rate limit: 30 AI chat messages per user per 10 minutes
|
||||
check_rate_limit(
|
||||
key=f"teach_chat:{current_user.id}",
|
||||
max_calls=30,
|
||||
window_seconds=600,
|
||||
detail="Too many AI chat messages. Please wait a few minutes before continuing.",
|
||||
)
|
||||
model_info = _get_teach_model(db, req.model_id)
|
||||
if not model_info:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from app.database import get_db
|
|||
from app.models.user import User
|
||||
from app.models.ai_model_config import AIModelConfig
|
||||
from app.services import ai_service
|
||||
from app.utils.auth import get_current_user
|
||||
from app.utils.auth import get_current_user, check_rate_limit
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -51,6 +51,13 @@ def text_to_speech(
|
|||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Convert text to speech using configured or user-selected TTS model."""
|
||||
# Rate limit: 60 TTS requests per user per hour
|
||||
check_rate_limit(
|
||||
key=f"tts_speak:{current_user.id}",
|
||||
max_calls=60,
|
||||
window_seconds=3600,
|
||||
detail="TTS rate limit reached. You can generate up to 60 audio clips per hour.",
|
||||
)
|
||||
if not request.text.strip():
|
||||
raise HTTPException(status_code=400, detail="Text cannot be empty")
|
||||
|
||||
|
|
|
|||
|
|
@ -117,3 +117,19 @@ class TokenRefreshMiddleware(BaseHTTPMiddleware):
|
|||
response.headers["X-New-Token"] = new_token
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def check_rate_limit(key: str, max_calls: int, window_seconds: int, detail: str):
|
||||
"""Generic Redis-backed rate limiter. Raises 429 if limit exceeded. Degrades gracefully if Redis is down."""
|
||||
try:
|
||||
import redis as redis_lib
|
||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
|
||||
count = r.incr(key)
|
||||
if count == 1:
|
||||
r.expire(key, window_seconds)
|
||||
if count > max_calls:
|
||||
raise HTTPException(status_code=429, detail=detail)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass # Redis unavailable — fail open rather than blocking users
|
||||
|
|
|
|||
3208
frontend/package-lock.json
generated
Normal file
3208
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -12,6 +12,7 @@
|
|||
"axios": "^1.6.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import api from '../api/client'
|
||||
|
||||
/**
|
||||
|
|
@ -196,9 +197,18 @@ export default function TeachChat({ question }) {
|
|||
fontSize: '0.85rem',
|
||||
lineHeight: 1.55,
|
||||
border: m.role === 'assistant' ? '1px solid var(--border)' : 'none',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{m.content}
|
||||
{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>
|
||||
))}
|
||||
{loading && (
|
||||
|
|
|
|||
Loading…
Reference in a new issue