language-learning-app/api/app/routers/api/vocab.py
wilson aa4987981d
Some checks are pending
/ test (push) Waiting to run
feat: Create the Dictionary Lookup Service; methods for fidning
vocabulary and words
2026-04-10 07:11:57 +01:00

218 lines
6.8 KiB
Python

import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from ...auth import verify_token
from ...domain.services.dictionary_lookup_service import DictionaryLookupService, TokenLookupResult
from ...domain.services.vocab_service import VocabService
from ...outbound.postgres.database import get_db
from ...outbound.postgres.repositories.dictionary_repository import PostgresDictionaryRepository
from ...outbound.postgres.repositories.vocab_repository import PostgresVocabRepository
router = APIRouter(prefix="/vocab", tags=["vocab"])
class AddWordRequest(BaseModel):
language_pair_id: str
surface_text: str
entry_pathway: str = "manual"
is_phrase: bool = False
source_article_id: str | None = None
class AddFromTokenRequest(BaseModel):
language_pair_id: str
surface: str
spacy_lemma: str
pos_ud: str
language: str
source_article_id: str | None = None
class SenseCandidateResponse(BaseModel):
id: str
gloss: str
topics: list[str]
tags: list[str]
class FromTokenResponse(BaseModel):
entry: "WordBankEntryResponse"
sense_candidates: list[SenseCandidateResponse]
matched_via: str
class SetSenseRequest(BaseModel):
sense_id: str
class WordBankEntryResponse(BaseModel):
id: str
user_id: str
language_pair_id: str
sense_id: str | None
wordform_id: str | None
surface_text: str
is_phrase: bool
entry_pathway: str
source_article_id: str | None
disambiguation_status: str
created_at: str
def _service(db: AsyncSession) -> VocabService:
return VocabService(
vocab_repo=PostgresVocabRepository(db),
dict_repo=PostgresDictionaryRepository(db),
)
@router.post("", response_model=WordBankEntryResponse, status_code=201)
async def add_word(
request: AddWordRequest,
db: AsyncSession = Depends(get_db),
token_data: dict = Depends(verify_token),
) -> WordBankEntryResponse:
user_id = uuid.UUID(token_data["sub"])
try:
language_pair_id = uuid.UUID(request.language_pair_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid language_pair_id")
source_article_id = None
if request.source_article_id:
try:
source_article_id = uuid.UUID(request.source_article_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid source_article_id")
try:
entry = await _service(db).add_word_to_bank(
user_id=user_id,
surface_text=request.surface_text.strip(),
language_pair_id=language_pair_id,
pathway=request.entry_pathway,
is_phrase=request.is_phrase,
source_article_id=source_article_id,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
return _to_response(entry)
@router.post("/from-token", response_model=FromTokenResponse, status_code=201)
async def add_from_token(
request: AddFromTokenRequest,
db: AsyncSession = Depends(get_db),
token_data: dict = Depends(verify_token),
) -> FromTokenResponse:
user_id = uuid.UUID(token_data["sub"])
try:
language_pair_id = uuid.UUID(request.language_pair_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid language_pair_id")
source_article_id = None
if request.source_article_id:
try:
source_article_id = uuid.UUID(request.source_article_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid source_article_id")
lookup_service = DictionaryLookupService(PostgresDictionaryRepository(db))
result: TokenLookupResult = await lookup_service.lookup_token(
surface=request.surface,
spacy_lemma=request.spacy_lemma,
pos_ud=request.pos_ud,
language=request.language,
)
wordform_id = uuid.UUID(result.wordform_id) if result.wordform_id else None
try:
entry = await _service(db).add_token_to_bank(
user_id=user_id,
surface_text=request.surface,
language_pair_id=language_pair_id,
senses=result.senses,
wordform_id=wordform_id,
source_article_id=source_article_id,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
candidates = [
SenseCandidateResponse(id=s.id, gloss=s.gloss, topics=s.topics, tags=s.tags)
for s in result.senses
]
return FromTokenResponse(
entry=_to_response(entry),
sense_candidates=candidates,
matched_via=result.matched_via,
)
@router.get("", response_model=list[WordBankEntryResponse])
async def list_entries(
language_pair_id: str,
db: AsyncSession = Depends(get_db),
token_data: dict = Depends(verify_token),
) -> list[WordBankEntryResponse]:
user_id = uuid.UUID(token_data["sub"])
try:
pair_id = uuid.UUID(language_pair_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid language_pair_id")
entries = await PostgresVocabRepository(db).get_entries_for_user(user_id, pair_id)
return [_to_response(e) for e in entries]
@router.get("/pending-disambiguation", response_model=list[WordBankEntryResponse])
async def pending_disambiguation(
db: AsyncSession = Depends(get_db),
token_data: dict = Depends(verify_token),
) -> list[WordBankEntryResponse]:
user_id = uuid.UUID(token_data["sub"])
entries = await PostgresVocabRepository(db).get_pending_disambiguation(user_id)
return [_to_response(e) for e in entries]
@router.patch("/{entry_id}/sense", response_model=WordBankEntryResponse)
async def resolve_sense(
entry_id: str,
request: SetSenseRequest,
db: AsyncSession = Depends(get_db),
token_data: dict = Depends(verify_token),
) -> WordBankEntryResponse:
try:
eid = uuid.UUID(entry_id)
sid = uuid.UUID(request.sense_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid UUID")
try:
entry = await _service(db).resolve_disambiguation(eid, sid)
except Exception:
raise HTTPException(status_code=404, detail="Entry not found")
return _to_response(entry)
def _to_response(entry) -> WordBankEntryResponse:
return WordBankEntryResponse(
id=entry.id,
user_id=entry.user_id,
language_pair_id=entry.language_pair_id,
sense_id=entry.sense_id,
wordform_id=entry.wordform_id,
surface_text=entry.surface_text,
is_phrase=entry.is_phrase,
entry_pathway=entry.entry_pathway,
source_article_id=entry.source_article_id,
disambiguation_status=entry.disambiguation_status,
created_at=entry.created_at.isoformat(),
)