Files
ljwx-children/backend/api/data.py
T
brunoandClaude Sonnet 4.5 9b7a0f6ec2 test: add comprehensive risk assessments API test suite (Issue #37)
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]>
2026-01-01 14:23:55 +08:00

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(),
)