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
|
from sqlalchemy import text as sa_text
|
||||||
emb = generate_embedding(q.strip())
|
emb = generate_embedding(q.strip())
|
||||||
if emb:
|
if emb:
|
||||||
emb_literal = "[" + ",".join(str(x) for x in emb) + "]"
|
# Validate all values are finite floats before interpolating into SQL
|
||||||
rows = db.execute(sa_text(f"""
|
emb_literal = "[" + ",".join(str(float(x)) for x in emb) + "]"
|
||||||
SELECT id, 1 - (embedding <=> '{emb_literal}'::vector) AS sim
|
rows = db.execute(sa_text("""
|
||||||
|
SELECT id, 1 - (embedding <=> CAST(:vec AS vector)) AS sim
|
||||||
FROM questions
|
FROM questions
|
||||||
WHERE embedding IS NOT NULL
|
WHERE embedding IS NOT NULL
|
||||||
ORDER BY embedding <=> '{emb_literal}'::vector
|
ORDER BY embedding <=> CAST(:vec AS vector)
|
||||||
LIMIT 200
|
LIMIT 200
|
||||||
""")).fetchall()
|
"""), {"vec": emb_literal}).fetchall()
|
||||||
semantic_ids_ordered = [r.id for r in rows if float(r.sim) >= 0.30]
|
semantic_ids_ordered = [r.id for r in rows if float(r.sim) >= 0.30]
|
||||||
|
|
||||||
# ── Keyword filter ─────────────────────────────────────────────
|
# ── Keyword filter ─────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from app.database import get_db
|
||||||
from app.models.question import Question
|
from app.models.question import Question
|
||||||
from app.models.ai_model_config import AIModelConfig
|
from app.models.ai_model_config import AIModelConfig
|
||||||
from app.models.user import User
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -54,14 +54,15 @@ def _find_similar_questions(db: Session, question: Question, limit: int = 4) ->
|
||||||
try:
|
try:
|
||||||
from sqlalchemy import text as sa_text
|
from sqlalchemy import text as sa_text
|
||||||
emb = question.embedding
|
emb = question.embedding
|
||||||
emb_literal = "[" + ",".join(str(x) for x in emb) + "]"
|
# Validate all values are finite floats before using in SQL
|
||||||
rows = db.execute(sa_text(f"""
|
emb_literal = "[" + ",".join(str(float(x)) for x in emb) + "]"
|
||||||
SELECT id, 1 - (embedding <=> '{emb_literal}'::vector) AS sim
|
rows = db.execute(sa_text("""
|
||||||
|
SELECT id, 1 - (embedding <=> CAST(:vec AS vector)) AS sim
|
||||||
FROM questions
|
FROM questions
|
||||||
WHERE embedding IS NOT NULL AND id != {question.id}
|
WHERE embedding IS NOT NULL AND id != :qid
|
||||||
ORDER BY embedding <=> '{emb_literal}'::vector
|
ORDER BY embedding <=> CAST(:vec AS vector)
|
||||||
LIMIT {limit}
|
LIMIT :lim
|
||||||
""")).fetchall()
|
"""), {"vec": emb_literal, "qid": int(question.id), "lim": int(limit)}).fetchall()
|
||||||
ids = [r.id for r in rows if float(r.sim) >= 0.35]
|
ids = [r.id for r in rows if float(r.sim) >= 0.35]
|
||||||
if not ids:
|
if not ids:
|
||||||
return []
|
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))
|
opts = "\n".join(f" {letters[i]}) {opt}" for i, opt in enumerate(question.options))
|
||||||
|
|
||||||
prompt = (
|
prompt = (
|
||||||
"You are a medical education tutor helping a student understand the following question. "
|
"You are a medical education tutor. A student is studying the question below.\n"
|
||||||
"Be accurate, educational, and concise. You may reveal and explain the correct answer.\n\n"
|
"Rules:\n"
|
||||||
f"=== Current Question ===\n{question.question_text}\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:
|
if opts:
|
||||||
prompt += f"Options:\n{opts}\n"
|
prompt += f"Options:\n{opts}\n"
|
||||||
|
|
@ -97,9 +103,9 @@ def _build_system_prompt(question: Question, similar: list[Question]) -> str:
|
||||||
prompt += "\n"
|
prompt += "\n"
|
||||||
|
|
||||||
prompt += (
|
prompt += (
|
||||||
"\nAnswer the student's questions about this topic. "
|
"\nAnswer the student's question directly. Explain the correct answer, "
|
||||||
"If they ask why an option is wrong, explain the underlying concept. "
|
"the underlying concept, and why wrong options are incorrect if relevant. "
|
||||||
"Keep responses focused and under 200 words unless a longer explanation is needed."
|
"Do not ask what they want to know — just teach."
|
||||||
)
|
)
|
||||||
return prompt
|
return prompt
|
||||||
|
|
||||||
|
|
@ -124,6 +130,13 @@ def chat(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Send a message to the teach AI with full question context."""
|
"""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)
|
model_info = _get_teach_model(db, req.model_id)
|
||||||
if not model_info:
|
if not model_info:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from app.database import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.ai_model_config import AIModelConfig
|
from app.models.ai_model_config import AIModelConfig
|
||||||
from app.services import ai_service
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -51,6 +51,13 @@ def text_to_speech(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Convert text to speech using configured or user-selected TTS model."""
|
"""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():
|
if not request.text.strip():
|
||||||
raise HTTPException(status_code=400, detail="Text cannot be empty")
|
raise HTTPException(status_code=400, detail="Text cannot be empty")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,3 +117,19 @@ class TokenRefreshMiddleware(BaseHTTPMiddleware):
|
||||||
response.headers["X-New-Token"] = new_token
|
response.headers["X-New-Token"] = new_token
|
||||||
|
|
||||||
return response
|
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",
|
"axios": "^1.6.7",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.22.0"
|
"react-router-dom": "^6.22.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -196,9 +197,18 @@ export default function TeachChat({ question }) {
|
||||||
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',
|
||||||
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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{loading && (
|
{loading && (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue