Created complete test coverage for risk assessment endpoints:
- 25 test cases covering all 3 endpoints
- POST /risk-assessments (11 tests)
- GET /risk-assessments (5 tests)
- GET /risk-assessments/{id} (5 tests)
- Edge cases and error handling (4 tests)
Test features:
✅ Access control (guardian, psychologist, administrator)
✅ Ollama integration (mocked with AsyncMock)
✅ Fallback mechanisms
✅ Pagination and filtering
✅ Error handling and edge cases
✅ Data validation
Dependencies and fixes:
- Updated httpx (0.26.0 → 0.27.2) for pytest-httpx compatibility
- Fixed JWT imports (import jwt → from jose import jwt)
- Fixed Pydantic v2 field_validator syntax
- Fixed model import conflicts (removed duplicate User model)
- Updated backend/models/__init__.py to import left_behind_children models
- Added missing auth exports (blacklist_token, is_token_blacklisted, can_access_risk_assessment)
Known issue (⚠️ BLOCKING):
- SQLite UUID type incompatibility preventing tests from running
- Error: SQLiteTypeCompiler can't render UUID type
- Need to implement UUID-to-String adapter for SQLite in tests
- All 25 tests fail at setup phase
Files created/modified:
- tests/unit/test_api_risk_assessments.py (NEW - 700+ lines)
- requirements.txt (updated httpx, commented safety)
- backend/auth/__init__.py (added exports)
- backend/auth/utils.py (fixed JWT import)
- backend/auth/dependencies.py (fixed JWT import)
- backend/api/data.py (fixed Pydantic validator)
- backend/models/__init__.py (switched to left_behind_children models)
Next steps:
1. Fix UUID/SQLite compatibility (use String(36) or UUID adapter)
2. Run tests successfully
3. Achieve >95% coverage target
Progress: Issue #37 - 70% complete (tests written, need execution)
Related: #38, #39, #40, #41, #42
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
492 lines
17 KiB
Python
492 lines
17 KiB
Python
"""
|
|
Data Collection API endpoints for Left-behind Children Platform.
|
|
|
|
Endpoints:
|
|
- POST /data/voice - Upload voice recording for emotion analysis
|
|
- POST /data/text - Submit text for sentiment analysis
|
|
- GET /data/voice/{id} - Get voice data details
|
|
- GET /data/text/{id} - Get text data details
|
|
|
|
Data Flow:
|
|
1. Voice: Upload → OSS storage → Alibaba ISI speech recognition → Emotion model → Store results
|
|
2. Text: Submit → Baidu ERNIE NLP → Sentiment analysis → Store results
|
|
"""
|
|
|
|
from typing import Optional
|
|
from datetime import datetime
|
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
|
|
from pydantic import BaseModel, Field, field_validator
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.future import select
|
|
from opentelemetry import trace
|
|
import logging
|
|
import random
|
|
import hashlib
|
|
|
|
from backend.auth.dependencies import get_current_active_user
|
|
from backend.models.left_behind_children import User, VoiceData, TextData
|
|
from backend.models import get_session
|
|
|
|
# Configure logging
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Create router
|
|
router = APIRouter(prefix="/data", tags=["Data Collection"])
|
|
|
|
|
|
# ============================================================================
|
|
# REQUEST/RESPONSE MODELS
|
|
# ============================================================================
|
|
|
|
class VoiceUploadResponse(BaseModel):
|
|
"""Voice upload response."""
|
|
id: str
|
|
child_id: str
|
|
file_url: str
|
|
file_size_bytes: int
|
|
duration_seconds: Optional[float]
|
|
transcript: Optional[str]
|
|
emotion: Optional[str]
|
|
emotion_confidence: Optional[float]
|
|
sentiment_score: Optional[float]
|
|
audio_quality_score: Optional[float]
|
|
created_at: str
|
|
|
|
class Config:
|
|
orm_mode = True
|
|
|
|
|
|
class TextSubmitRequest(BaseModel):
|
|
"""Text data submission request."""
|
|
child_id: str = Field(..., description="Child user ID")
|
|
content: str = Field(..., min_length=1, max_length=10000, description="Text content (max 10,000 characters)")
|
|
source: str = Field("manual_entry", description="Source: manual_entry, voice_transcript, chat_message")
|
|
|
|
@field_validator('source')
|
|
@classmethod
|
|
def validate_source(cls, v: str) -> str:
|
|
"""Validate source type."""
|
|
allowed_sources = ["manual_entry", "voice_transcript", "chat_message"]
|
|
if v not in allowed_sources:
|
|
raise ValueError(f"Source must be one of: {', '.join(allowed_sources)}")
|
|
return v
|
|
|
|
|
|
class TextSubmitResponse(BaseModel):
|
|
"""Text submission response."""
|
|
id: str
|
|
child_id: str
|
|
content: str
|
|
sentiment: Optional[str]
|
|
sentiment_score: Optional[float]
|
|
keywords_extracted: Optional[list]
|
|
risk_indicators: Optional[list]
|
|
emotion: Optional[str]
|
|
emotion_confidence: Optional[float]
|
|
created_at: str
|
|
|
|
class Config:
|
|
orm_mode = True
|
|
|
|
|
|
# ============================================================================
|
|
# HELPER FUNCTIONS (Mock AI/ML - TODO: Replace with real APIs)
|
|
# ============================================================================
|
|
|
|
def mock_upload_to_oss(file_content: bytes, filename: str) -> str:
|
|
"""
|
|
Mock OSS (Alibaba Cloud Object Storage Service) upload.
|
|
|
|
TODO: Replace with real OSS upload:
|
|
- Use aliyun-python-sdk-oss
|
|
- Upload to bucket: left-behind-children-voice
|
|
- Generate signed URL with 7-day expiry
|
|
- Return: https://oss-cn-beijing.aliyuncs.com/left-behind-children-voice/{uuid}.wav
|
|
|
|
Args:
|
|
file_content: Audio file bytes
|
|
filename: Original filename
|
|
|
|
Returns:
|
|
str: Mock OSS URL
|
|
"""
|
|
# Generate mock file hash for "unique" URL
|
|
file_hash = hashlib.md5(file_content[:100]).hexdigest()[:12]
|
|
|
|
return f"https://mock-oss.aliyuncs.com/voice-data/{file_hash}_{filename}"
|
|
|
|
|
|
async def analyze_voice_with_ollama(file_url: str, duration_seconds: float) -> dict:
|
|
"""
|
|
Voice emotion analysis using Ollama lingjingwanxiang:32b.
|
|
|
|
Replaces: Alibaba Cloud ISI + PyTorch LSTM emotion model
|
|
|
|
Process:
|
|
1. Generate mock transcript (TODO: Real speech-to-text via Whisper/Alibaba ISI)
|
|
2. Analyze emotion using Ollama lingjingwanxiang:32b
|
|
3. Return emotion, confidence, sentiment score
|
|
|
|
Returns:
|
|
dict: {
|
|
"transcript": str,
|
|
"emotion": str,
|
|
"emotion_confidence": float,
|
|
"sentiment_score": float,
|
|
"audio_quality_score": float
|
|
}
|
|
"""
|
|
from backend.ollama_client import get_ollama_client
|
|
|
|
# TODO: Real speech-to-text implementation
|
|
# For now, generate plausible transcript
|
|
transcripts = [
|
|
"我今天很想爸爸妈妈,他们什么时候回来...",
|
|
"今天在学校很开心,和朋友们一起玩!",
|
|
"我感觉有点孤独,没有人陪我说话",
|
|
"作业太多了,我有点担心考试",
|
|
"老师表扬了我,我很高兴!"
|
|
]
|
|
transcript = random.choice(transcripts)
|
|
|
|
try:
|
|
# Analyze using Ollama
|
|
ollama = await get_ollama_client()
|
|
analysis = await ollama.analyze_voice_emotion(
|
|
transcript=transcript,
|
|
duration_seconds=duration_seconds,
|
|
audio_quality_score=round(random.uniform(0.75, 0.95), 2)
|
|
)
|
|
|
|
return {
|
|
"transcript": transcript,
|
|
"emotion": analysis["emotion"],
|
|
"emotion_confidence": analysis["emotion_confidence"],
|
|
"sentiment_score": analysis["sentiment_score"],
|
|
"audio_quality_score": round(random.uniform(0.75, 0.95), 2),
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Ollama voice analysis failed: {str(e)}, using fallback")
|
|
# Fallback to simple analysis
|
|
emotion = "neutral"
|
|
if "想" in transcript or "孤独" in transcript:
|
|
emotion = "sad"
|
|
elif "开心" in transcript or "高兴" in transcript:
|
|
emotion = "happy"
|
|
elif "担心" in transcript or "焦虑" in transcript:
|
|
emotion = "anxious"
|
|
|
|
return {
|
|
"transcript": transcript,
|
|
"emotion": emotion,
|
|
"emotion_confidence": 0.70,
|
|
"sentiment_score": -0.3 if emotion in ["sad", "anxious"] else 0.3,
|
|
"audio_quality_score": 0.80,
|
|
}
|
|
|
|
|
|
async def analyze_text_with_ollama(content: str, context: Optional[str] = None) -> dict:
|
|
"""
|
|
Text sentiment analysis using Ollama lingjingwanxiang:32b.
|
|
|
|
Replaces: Baidu ERNIE NLP
|
|
|
|
Process:
|
|
1. Analyze text using Ollama lingjingwanxiang:32b
|
|
2. Extract sentiment, keywords, risk indicators
|
|
3. Return comprehensive analysis
|
|
|
|
Returns:
|
|
dict: {
|
|
"sentiment": str,
|
|
"sentiment_score": float,
|
|
"keywords_extracted": list,
|
|
"risk_indicators": list,
|
|
"emotion": str,
|
|
"emotion_confidence": float
|
|
}
|
|
"""
|
|
from backend.ollama_client import get_ollama_client
|
|
|
|
try:
|
|
# Analyze using Ollama
|
|
ollama = await get_ollama_client()
|
|
analysis = await ollama.analyze_text_sentiment(
|
|
content=content,
|
|
context=context
|
|
)
|
|
|
|
return {
|
|
"sentiment": analysis["sentiment"],
|
|
"sentiment_score": analysis["sentiment_score"],
|
|
"keywords_extracted": analysis["keywords_extracted"],
|
|
"risk_indicators": analysis["risk_indicators"],
|
|
"emotion": analysis["emotion"],
|
|
"emotion_confidence": analysis["emotion_confidence"],
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Ollama text analysis failed: {str(e)}, using fallback")
|
|
# Fallback to keyword-based analysis
|
|
risk_keywords = ["想死", "自杀", "不想活", "讨厌自己", "没人爱我"]
|
|
negative_keywords = ["孤独", "害怕", "难过", "想哭", "痛苦"]
|
|
|
|
risk_indicators = []
|
|
for keyword in risk_keywords:
|
|
if keyword in content:
|
|
risk_indicators.append({
|
|
"keyword": keyword,
|
|
"category": "self_harm_risk",
|
|
"severity": "critical"
|
|
})
|
|
|
|
has_risk = len(risk_indicators) > 0
|
|
has_negative = any(kw in content for kw in negative_keywords)
|
|
|
|
if has_risk:
|
|
sentiment = "very_negative"
|
|
sentiment_score = -0.8
|
|
emotion = "despair"
|
|
elif has_negative:
|
|
sentiment = "negative"
|
|
sentiment_score = -0.5
|
|
emotion = "sad"
|
|
else:
|
|
sentiment = "neutral"
|
|
sentiment_score = 0.0
|
|
emotion = "neutral"
|
|
|
|
return {
|
|
"sentiment": sentiment,
|
|
"sentiment_score": sentiment_score,
|
|
"keywords_extracted": [kw for kw in negative_keywords if kw in content][:5],
|
|
"risk_indicators": risk_indicators,
|
|
"emotion": emotion,
|
|
"emotion_confidence": 0.70,
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# DATA COLLECTION ENDPOINTS
|
|
# ============================================================================
|
|
|
|
@router.post("/voice", response_model=VoiceUploadResponse, status_code=status.HTTP_201_CREATED)
|
|
async def upload_voice(
|
|
child_id: str = Form(..., description="Child user ID"),
|
|
file: UploadFile = File(..., description="Audio file (WAV, MP3, M4A)"),
|
|
current_user: User = Depends(get_current_active_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""
|
|
Upload voice recording for emotion analysis.
|
|
|
|
**Process:**
|
|
1. Validate file type and size (max 50MB)
|
|
2. Upload to Alibaba Cloud OSS
|
|
3. Trigger speech recognition (Alibaba ISI)
|
|
4. Run emotion detection model (LSTM)
|
|
5. Store metadata and analysis results
|
|
|
|
**Access Control:**
|
|
- Children can upload their own voice data
|
|
- Guardians can upload for assigned children (TODO)
|
|
|
|
**File Requirements:**
|
|
- Format: WAV, MP3, M4A
|
|
- Max size: 50MB
|
|
- Min duration: 1 second
|
|
- Max duration: 300 seconds (5 minutes)
|
|
|
|
**Returns:**
|
|
- 201: Voice data uploaded and analyzed
|
|
- 400: Invalid file format or size
|
|
- 403: No permission to upload for this child
|
|
- 413: File too large
|
|
"""
|
|
tracer = trace.get_tracer(__name__)
|
|
|
|
with tracer.start_as_current_span("upload_voice") as span:
|
|
span.set_attribute("child_id", child_id)
|
|
span.set_attribute("uploader_id", str(current_user.id))
|
|
span.set_attribute("filename", file.filename)
|
|
|
|
# Validate child exists and user has permission
|
|
stmt = select(User).where(User.id == child_id, User.role == "child")
|
|
result = await session.execute(stmt)
|
|
child = result.scalar_one_or_none()
|
|
|
|
if not child:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Child not found"
|
|
)
|
|
|
|
# TODO: Check if current_user has permission to upload for this child
|
|
# For now: allow only the child themselves
|
|
if str(current_user.id) != child_id and current_user.role != "administrator":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="You can only upload voice data for yourself"
|
|
)
|
|
|
|
# Read file content
|
|
file_content = await file.read()
|
|
file_size_bytes = len(file_content)
|
|
|
|
# Validate file size (max 50MB)
|
|
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
|
|
if file_size_bytes > MAX_FILE_SIZE:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
detail=f"File too large (max {MAX_FILE_SIZE // 1024 // 1024}MB)"
|
|
)
|
|
|
|
# Validate file type
|
|
allowed_extensions = [".wav", ".mp3", ".m4a"]
|
|
if not any(file.filename.lower().endswith(ext) for ext in allowed_extensions):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid file type. Allowed: {', '.join(allowed_extensions)}"
|
|
)
|
|
|
|
span.set_attribute("file_size_bytes", file_size_bytes)
|
|
|
|
# Upload to OSS (MOCK)
|
|
file_url = mock_upload_to_oss(file_content, file.filename)
|
|
logger.info(f"Voice file uploaded to OSS: {file_url}")
|
|
|
|
# Mock audio duration (TODO: Extract from audio metadata)
|
|
duration_seconds = round(random.uniform(5.0, 120.0), 1)
|
|
|
|
# Analyze voice with Ollama
|
|
analysis = await analyze_voice_with_ollama(file_url, duration_seconds)
|
|
|
|
# Create voice data record
|
|
voice_data = VoiceData(
|
|
child_id=child_id,
|
|
file_url=file_url,
|
|
file_size_bytes=file_size_bytes,
|
|
duration_seconds=duration_seconds,
|
|
transcript=analysis["transcript"],
|
|
emotion=analysis["emotion"],
|
|
emotion_confidence=analysis["emotion_confidence"],
|
|
sentiment_score=analysis["sentiment_score"],
|
|
audio_quality_score=analysis["audio_quality_score"],
|
|
)
|
|
|
|
session.add(voice_data)
|
|
await session.commit()
|
|
await session.refresh(voice_data)
|
|
|
|
span.set_attribute("voice_data_id", str(voice_data.id))
|
|
span.set_attribute("emotion", analysis["emotion"])
|
|
|
|
logger.info(f"Voice data created: {voice_data.id} (emotion: {analysis['emotion']}, sentiment: {analysis['sentiment_score']})")
|
|
|
|
return VoiceUploadResponse(
|
|
id=str(voice_data.id),
|
|
child_id=str(voice_data.child_id),
|
|
file_url=voice_data.file_url,
|
|
file_size_bytes=voice_data.file_size_bytes,
|
|
duration_seconds=voice_data.duration_seconds,
|
|
transcript=voice_data.transcript,
|
|
emotion=voice_data.emotion,
|
|
emotion_confidence=voice_data.emotion_confidence,
|
|
sentiment_score=voice_data.sentiment_score,
|
|
audio_quality_score=voice_data.audio_quality_score,
|
|
created_at=voice_data.created_at.isoformat(),
|
|
)
|
|
|
|
|
|
@router.post("/text", response_model=TextSubmitResponse, status_code=status.HTTP_201_CREATED)
|
|
async def submit_text(
|
|
request: TextSubmitRequest,
|
|
current_user: User = Depends(get_current_active_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""
|
|
Submit text for sentiment analysis.
|
|
|
|
**Process:**
|
|
1. Validate child exists
|
|
2. Run NLP analysis (Baidu ERNIE)
|
|
3. Extract keywords and risk indicators
|
|
4. Calculate sentiment score
|
|
5. Store results
|
|
|
|
**Access Control:**
|
|
- Children can submit their own text data
|
|
- Guardians can submit for assigned children (TODO)
|
|
|
|
**Returns:**
|
|
- 201: Text analyzed and stored
|
|
- 403: No permission
|
|
- 404: Child not found
|
|
"""
|
|
tracer = trace.get_tracer(__name__)
|
|
|
|
with tracer.start_as_current_span("submit_text") as span:
|
|
span.set_attribute("child_id", request.child_id)
|
|
span.set_attribute("submitter_id", str(current_user.id))
|
|
span.set_attribute("content_length", len(request.content))
|
|
|
|
# Validate child exists
|
|
stmt = select(User).where(User.id == request.child_id, User.role == "child")
|
|
result = await session.execute(stmt)
|
|
child = result.scalar_one_or_none()
|
|
|
|
if not child:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Child not found"
|
|
)
|
|
|
|
# TODO: Check permission
|
|
if str(current_user.id) != request.child_id and current_user.role != "administrator":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="You can only submit text data for yourself"
|
|
)
|
|
|
|
# Analyze text with Ollama
|
|
analysis = await analyze_text_with_ollama(request.content, context=request.source)
|
|
|
|
# Create text data record
|
|
text_data = TextData(
|
|
child_id=request.child_id,
|
|
content=request.content,
|
|
source=request.source,
|
|
sentiment=analysis["sentiment"],
|
|
sentiment_score=analysis["sentiment_score"],
|
|
keywords_extracted=analysis["keywords_extracted"],
|
|
risk_indicators=analysis["risk_indicators"],
|
|
emotion=analysis["emotion"],
|
|
emotion_confidence=analysis["emotion_confidence"],
|
|
)
|
|
|
|
session.add(text_data)
|
|
await session.commit()
|
|
await session.refresh(text_data)
|
|
|
|
span.set_attribute("text_data_id", str(text_data.id))
|
|
span.set_attribute("sentiment", analysis["sentiment"])
|
|
span.set_attribute("has_risk_indicators", len(analysis["risk_indicators"]) > 0)
|
|
|
|
logger.info(f"Text data created: {text_data.id} (sentiment: {analysis['sentiment']}, risk_indicators: {len(analysis['risk_indicators'])})")
|
|
|
|
# TODO: If critical risk indicators found, trigger immediate alert
|
|
|
|
return TextSubmitResponse(
|
|
id=str(text_data.id),
|
|
child_id=str(text_data.child_id),
|
|
content=text_data.content,
|
|
sentiment=text_data.sentiment,
|
|
sentiment_score=text_data.sentiment_score,
|
|
keywords_extracted=text_data.keywords_extracted,
|
|
risk_indicators=text_data.risk_indicators,
|
|
emotion=text_data.emotion,
|
|
emotion_confidence=text_data.emotion_confidence,
|
|
created_at=text_data.created_at.isoformat(),
|
|
)
|