From 407d423a4c13cbb4005f87c1a1accb88da2bbb14 Mon Sep 17 00:00:00 2001 From: wilson Date: Thu, 26 Mar 2026 20:47:15 +0000 Subject: [PATCH] API: allow free access to audio files; create the article_service --- api/app/domain/__init__.py | 0 api/app/domain/models/translated_article.py | 15 +++++ api/app/domain/services/article_service.py | 30 ++++++++++ api/app/main.py | 2 + api/app/outbound/postgres/database.py | 2 +- .../repositories/summarise_job_repository.py | 56 +++++++++++++++---- api/app/routers/api/generation.py | 1 - api/app/routers/bff/__init__.py | 0 api/app/routers/bff/articles.py | 30 ++++++++++ api/app/routers/bff/main.py | 7 +++ api/app/routers/media.py | 7 +-- 11 files changed, 132 insertions(+), 18 deletions(-) create mode 100644 api/app/domain/__init__.py create mode 100644 api/app/domain/models/translated_article.py create mode 100644 api/app/domain/services/article_service.py create mode 100644 api/app/routers/bff/__init__.py create mode 100644 api/app/routers/bff/articles.py create mode 100644 api/app/routers/bff/main.py diff --git a/api/app/domain/__init__.py b/api/app/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/domain/models/translated_article.py b/api/app/domain/models/translated_article.py new file mode 100644 index 0000000..600aace --- /dev/null +++ b/api/app/domain/models/translated_article.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass + +@dataclass +class TranslatedArticle: + id: str + + source_lang: str + source_title: str + source_text: str + + target_lang: str + target_title: str + target_text: str + + diff --git a/api/app/domain/services/article_service.py b/api/app/domain/services/article_service.py new file mode 100644 index 0000000..4c63e71 --- /dev/null +++ b/api/app/domain/services/article_service.py @@ -0,0 +1,30 @@ +import re + +from ..models.summarise_job import SummariseJob +from ..models.translated_article import TranslatedArticle + +def first_heading(md: str) -> str | None: + m = re.search(r'^#{1,2}\s+(.+)', md, re.MULTILINE) + return m.group(1).strip() if m else None + +class ArticleService: + def __init__(self, summarise_job_repository): + self.summarise_job_repository = summarise_job_repository + + async def get_all_articles(self) -> list[TranslatedArticle]: + summarise_jobs = await self.summarise_job_repository.list_all() + return summarise_jobs.map(self.summarise_job_to_translated_article) + + def summarise_job_to_translated_article( + self, + summarise_job: SummariseJob, + ) -> TranslatedArticle: + return TranslatedArticle( + id=summarise_job.id, + source_lang=summarise_job.target_language, # The source language for the article is the target language of the job + source_title=first_heading(summarise_job.translated_text) or "", + source_text=summarise_job.translated_text, + target_lang=summarise_job.source_language, # The target language for the article is the source language of the job + target_title=first_heading(summarise_job.generated_text) or "", + target_text=summarise_job.generated_text, + ) diff --git a/api/app/main.py b/api/app/main.py index 654f598..92224ee 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -8,6 +8,7 @@ from .routers.api import jobs from .routers import auth as auth_router from .routers import media as media_router from .routers.api.main import api_router +from .routers.bff.main import bff_router from .storage import ensure_bucket_exists from . import worker @@ -27,6 +28,7 @@ async def lifespan(app: FastAPI): app = FastAPI(title="Language Learning API", lifespan=lifespan) app.include_router(api_router) +app.include_router(bff_router) app.include_router(auth_router.router) app.include_router(media_router.router) diff --git a/api/app/outbound/postgres/database.py b/api/app/outbound/postgres/database.py index 5dbb6e0..24c20ad 100644 --- a/api/app/outbound/postgres/database.py +++ b/api/app/outbound/postgres/database.py @@ -11,6 +11,6 @@ class Base(DeclarativeBase): pass -async def get_db(): +async def get_db() -> AsyncSession: async with AsyncSessionLocal() as session: yield session diff --git a/api/app/outbound/postgres/repositories/summarise_job_repository.py b/api/app/outbound/postgres/repositories/summarise_job_repository.py index 1b7ebbc..a0c67d5 100644 --- a/api/app/outbound/postgres/repositories/summarise_job_repository.py +++ b/api/app/outbound/postgres/repositories/summarise_job_repository.py @@ -5,7 +5,51 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from ..entities.summarise_job_entity import SummariseJobEntity +from ....domain.models.summarise_job import SummariseJob +class PostgresSummariseJobRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def list_all(self) -> list[SummariseJob]: + result = self.db.execute( + select(SummariseJobEntity).order_by(SummariseJobEntity.created_at.desc()) + ) + + return list(result.scalars().all()).map(self.entity_to_model) + + async def get_by_audio_url( + self, + audio_url: str + ) -> SummariseJob | None: + result = await self.db.execute( + select(SummariseJobEntity).where( + SummariseJobEntity.audio_url == audio_url + ) + ) + + return self.entity_to_model(result.scalar_one_or_none()) + + def entity_to_model(self, entity: SummariseJobEntity | None) -> SummariseJob: + if entity is None: + return None + + return SummariseJob( + id=str(entity.id), + user_id=str(entity.user_id), + status=entity.status, + source_language=entity.source_language, + target_language=entity.target_language, + complexity_level=entity.complexity_level, + input_summary=entity.input_summary, + generated_text=entity.generated_text, + translated_text=entity.translated_text, + error_message=entity.error_message, + audio_url=entity.audio_url, + created_at=entity.created_at, + started_at=entity.started_at, + completed_at=entity.completed_at, + ) async def update(db: AsyncSession, job: SummariseJobEntity) -> None: await db.commit() @@ -41,18 +85,6 @@ async def list_all(db: AsyncSession) -> list[SummariseJobEntity]: return list(result.scalars().all()) -async def get_by_audio_url_and_user( - db: AsyncSession, audio_url: str, user_id: uuid.UUID -) -> SummariseJobEntity | None: - result = await db.execute( - select(SummariseJobEntity).where( - SummariseJobEntity.audio_url == audio_url, - SummariseJobEntity.user_id == user_id, - ) - ) - return result.scalar_one_or_none() - - async def mark_processing(db: AsyncSession, job: SummariseJobEntity) -> None: job.status = "processing" job.started_at = datetime.now(timezone.utc) diff --git a/api/app/routers/api/generation.py b/api/app/routers/api/generation.py index cf06f26..93beb06 100644 --- a/api/app/routers/api/generation.py +++ b/api/app/routers/api/generation.py @@ -23,7 +23,6 @@ class GenerationRequest(BaseModel): target_language: str complexity_level: str input_texts: list[str] - topic: str | None = None source_language: str = "en" diff --git a/api/app/routers/bff/__init__.py b/api/app/routers/bff/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/routers/bff/articles.py b/api/app/routers/bff/articles.py new file mode 100644 index 0000000..5145bd8 --- /dev/null +++ b/api/app/routers/bff/articles.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from ...domain.services.article_service import ArticleService +from ...outbound.postgres.database import get_db, AsyncSessionLocal +from ...outbound.postgres.repositories.summarise_job_repository import PostgresSummariseJobRepository + + +router = APIRouter(prefix="/articles", tags=["articles"]) + + +class ArticleResponse(BaseModel): + target_language: str + complexity_level: str + input_texts: list[str] + +class ArticlesResponse(BaseModel): + articles: list[ArticleResponse] + +@router.get("", response_model=ArticlesResponse, status_code=200) +async def get_articles( + db = Depends(get_db), +) -> ArticlesResponse: + service = ArticleService(PostgresSummariseJobRepository(db)) + + try: + articles = await service.get_all_articles() + return ArticlesResponse(articles=articles) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/api/app/routers/bff/main.py b/api/app/routers/bff/main.py new file mode 100644 index 0000000..efd7c76 --- /dev/null +++ b/api/app/routers/bff/main.py @@ -0,0 +1,7 @@ +from .articles import router as article_router + +from fastapi import APIRouter + +bff_router = APIRouter(prefix="/bff", tags=["bff"]) + +bff_router.include_router(article_router) diff --git a/api/app/routers/media.py b/api/app/routers/media.py index 52ad30f..d6d6c58 100644 --- a/api/app/routers/media.py +++ b/api/app/routers/media.py @@ -7,7 +7,7 @@ from botocore.exceptions import ClientError from ..auth import verify_token from ..outbound.postgres.database import get_db -from ..outbound.postgres.repositories import summarise_job_repository +from ..outbound.postgres.repositories.summarise_job_repository import PostgresSummariseJobRepository from ..storage import download_audio router = APIRouter(prefix="/media", tags=["media"]) @@ -17,11 +17,10 @@ router = APIRouter(prefix="/media", tags=["media"]) async def get_media_file( filename: str, db: AsyncSession = Depends(get_db), - token_data: dict = Depends(verify_token), ) -> Response: - user_id = uuid.UUID(token_data["sub"]) + repository = PostgresSummariseJobRepository(db) + job = await repository.get_by_audio_url(filename) - job = await summarise_job_repository.get_by_audio_url_and_user(db, filename, user_id) if job is None: raise HTTPException(status_code=404, detail="File not found")