From 271519204ce513d2ac4308987bcbdb3112b4af74 Mon Sep 17 00:00:00 2001 From: wilson Date: Wed, 25 Mar 2026 21:10:10 +0000 Subject: [PATCH] Start architecting things into domain driven design / hexagonal architecture --- api/alembic/env.py | 6 +- api/app/domain/models/__init__.py | 0 api/app/domain/models/summarise_job.py | 25 +++++ api/app/domain/services/__init__.py | 0 .../services/content_generation_service.py | 1 + api/app/outbound/__init__.py | 0 api/app/outbound/anthropic/__init__.py | 0 .../outbound/anthropic/anthropic_client.py | 74 +++++++++++++++ api/app/outbound/deepl/__init__.py | 0 api/app/outbound/deepl/deepl_client.py | 38 ++++++++ api/app/outbound/gemini/__init__.py | 0 api/app/outbound/gemini/gemini_client.py | 52 ++++++++++ api/app/outbound/postgres/__init__.py | 0 api/app/{ => outbound/postgres}/database.py | 2 +- .../outbound/postgres/entities/__init__.py | 0 .../entities/summarise_job_entity.py} | 24 +---- .../outbound/postgres/entities/user_entity.py | 27 ++++++ .../postgres/repositories/__init__.py | 0 .../repositories/summarise_job_repository.py | 94 +++++++++++++++++++ .../postgres/repositories/user_repository.py | 17 ++++ api/app/routers/api/generation.py | 44 ++++----- api/app/routers/api/jobs.py | 29 +++--- api/app/routers/api/translate.py | 11 ++- api/app/routers/auth.py | 20 ++-- api/app/routers/media.py | 10 +- api/app/services/job_repo.py | 46 --------- api/app/services/llm.py | 75 --------------- api/app/services/translate.py | 33 ------- api/app/services/tts.py | 39 -------- api/architecture.md | 48 ++++++++++ 30 files changed, 439 insertions(+), 276 deletions(-) create mode 100644 api/app/domain/models/__init__.py create mode 100644 api/app/domain/models/summarise_job.py create mode 100644 api/app/domain/services/__init__.py create mode 100644 api/app/domain/services/content_generation_service.py create mode 100644 api/app/outbound/__init__.py create mode 100644 api/app/outbound/anthropic/__init__.py create mode 100644 api/app/outbound/anthropic/anthropic_client.py create mode 100644 api/app/outbound/deepl/__init__.py create mode 100644 api/app/outbound/deepl/deepl_client.py create mode 100644 api/app/outbound/gemini/__init__.py create mode 100644 api/app/outbound/gemini/gemini_client.py create mode 100644 api/app/outbound/postgres/__init__.py rename api/app/{ => outbound/postgres}/database.py (92%) create mode 100644 api/app/outbound/postgres/entities/__init__.py rename api/app/{models.py => outbound/postgres/entities/summarise_job_entity.py} (64%) create mode 100644 api/app/outbound/postgres/entities/user_entity.py create mode 100644 api/app/outbound/postgres/repositories/__init__.py create mode 100644 api/app/outbound/postgres/repositories/summarise_job_repository.py create mode 100644 api/app/outbound/postgres/repositories/user_repository.py delete mode 100644 api/app/services/job_repo.py delete mode 100644 api/app/services/llm.py delete mode 100644 api/app/services/translate.py delete mode 100644 api/app/services/tts.py create mode 100644 api/architecture.md diff --git a/api/alembic/env.py b/api/alembic/env.py index f90fd9a..69d3756 100644 --- a/api/alembic/env.py +++ b/api/alembic/env.py @@ -6,8 +6,10 @@ from sqlalchemy import pool from sqlalchemy.ext.asyncio import async_engine_from_config from app.config import settings -from app.database import Base -import app.models # noqa: F401 — register all models with Base.metadata +from app.outbound.postgres.database import Base + +import app.outbound.postgres.entities.summarise_job_entity +import app.outbound.postgres.entities.user_entity config = context.config config.set_main_option("sqlalchemy.url", settings.database_url) diff --git a/api/app/domain/models/__init__.py b/api/app/domain/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/domain/models/summarise_job.py b/api/app/domain/models/summarise_job.py new file mode 100644 index 0000000..c7a6db2 --- /dev/null +++ b/api/app/domain/models/summarise_job.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from datetime import datetime, timezone + + +@dataclass +class SummariseJob: + id: str + user_id: str + status: str + source_language: str + target_language: str + complexity_level: str + input_summary: str + generated_text: str + translated_text: str + error_message: str + audio_url: str + created_at: datetime + started_at: datetime | None = None + completed_at: datetime | None = None + updated_at: datetime = None + + def __post_init__(self): + if self.updated_at is None: + self.updated_at = datetime.now(timezone.utc) diff --git a/api/app/domain/services/__init__.py b/api/app/domain/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/domain/services/content_generation_service.py b/api/app/domain/services/content_generation_service.py new file mode 100644 index 0000000..bbe09a8 --- /dev/null +++ b/api/app/domain/services/content_generation_service.py @@ -0,0 +1 @@ +# TODO: Implement this service, taking the code currently placed in app/routes/api/generation.py diff --git a/api/app/outbound/__init__.py b/api/app/outbound/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/outbound/anthropic/__init__.py b/api/app/outbound/anthropic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/outbound/anthropic/anthropic_client.py b/api/app/outbound/anthropic/anthropic_client.py new file mode 100644 index 0000000..0fd6e9e --- /dev/null +++ b/api/app/outbound/anthropic/anthropic_client.py @@ -0,0 +1,74 @@ +import asyncio +import anthropic + + +class AnthropicClient(): + def __init__(self, api_key: str): + self._client = anthropic.Anthropic(api_key=api_key) + + @classmethod + def new(cls, api_key: str) -> "AnthropicClient": + return cls(api_key) + + def _create_summarise_text_system_prompt( + self, + complexity_level: str, + from_language: str, + to_language: str, + length_preference="200-400 words", + ) -> str: + return ( + f"You are a language learning content creator.\n" + f"The user will provide input, you will generate an engaging realistic summary text in {to_language} " + f"at {complexity_level} proficiency level (CEFR scale).\n\n" + f"The text you generate will:\n" + f"- Contain ONLY the generated text in {to_language}.\n" + f"- Be appropriate for a {complexity_level} {to_language} speaker.\n" + f"- Never generate inappropriate (hateful, sexual, violent) content. It is preferable to return no text than to generate such content.\n" + f"- Speak directly to the reader/listener, adopting the tone and style of a semi-formal news reporter or podcaster.\n" + f"- Where appropriate (fluency level, content), use a small number of idiomatic expressions.\n" + f"- Be formatted in markdown with paragraphs and line breaks.\n" + f"- Be {length_preference} long.\n" + f"- Be inspired by the following source material " + f"(but written originally in {from_language}):\n\n" + ) + + def _create_prompt_summarise_text( + self, + source_material: str, + ) -> str: + return ( + f"Source material follows: \n\n" + f"{source_material}" + ) + + async def generate_summary_text( + self, + content_to_summarise: str, + complexity_level: str, + from_language: str, + to_language: str, + length_preference="200-400 words") -> str: + """Generate text using Anthropic.""" + def _call() -> str: + message = self._client.messages.create( + model="claude-sonnet-4-6", + max_tokens=1024, + system=self._create_summarise_text_system_prompt( + complexity_level=complexity_level, + from_language=from_language, + to_language=to_language, + length_preference=length_preference, + ), + messages=[ + { + "role": "user", + "content": self._create_prompt_summarise_text( + content_to_summarise + ) + } + ], + ) + return message.content[0].text + + return await asyncio.to_thread(_call) diff --git a/api/app/outbound/deepl/__init__.py b/api/app/outbound/deepl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/outbound/deepl/deepl_client.py b/api/app/outbound/deepl/deepl_client.py new file mode 100644 index 0000000..dc72ebd --- /dev/null +++ b/api/app/outbound/deepl/deepl_client.py @@ -0,0 +1,38 @@ +import httpx + +DEEPL_API_URL = "https://api-free.deepl.com/v2/translate" + +DEEPL_LANGUAGE_CODES = { + "en": "EN-GB", + "fr": "FR", + "es": "ES", + "it": "IT", + "de": "DE", +} + + +class DeepLClient(): + def __init__(self, api_key: str): + self._api_key = api_key + + def can_translate_to(self, the_language: str) -> bool: + return the_language in DEEPL_LANGUAGE_CODES + + async def translate(self, text: str, to_language: str, context: str | None = None) -> str: + target_lang_code = DEEPL_LANGUAGE_CODES[to_language] + async with httpx.AsyncClient() as client: + response = await client.post( + DEEPL_API_URL, + headers={ + "Authorization": f"DeepL-Auth-Key {self._api_key}", + "Content-Type": "application/json", + }, + json={ + "text": [text], + "target_lang": target_lang_code, + "context": context or None, + }, + ) + response.raise_for_status() + data = response.json() + return data["translations"][0]["text"] diff --git a/api/app/outbound/gemini/__init__.py b/api/app/outbound/gemini/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/outbound/gemini/gemini_client.py b/api/app/outbound/gemini/gemini_client.py new file mode 100644 index 0000000..a00665e --- /dev/null +++ b/api/app/outbound/gemini/gemini_client.py @@ -0,0 +1,52 @@ +import asyncio + +from google import genai +from google.genai import types as genai_types + +from ...storage import pcm_to_wav + +VOICE_BY_LANGUAGE: dict[str, str] = { + "fr": "Kore", + "es": "Charon", + "it": "Aoede", + "de": "Fenrir", + "en": "Kore", +} + + +class GeminiClient(): + """Communicate with Google's Gemini LLM""" + def __init__(self, api_key: str): + self._api_key = api_key + + def get_voice_by_language(self, target_language: str) -> str: + possible_voice = VOICE_BY_LANGUAGE.get(target_language) + + if not possible_voice: + raise ValueError(f"No voice found for language: {target_language}") + + return possible_voice + + + async def generate_audio(self, text: str, voice: str) -> bytes: + """Generate TTS audio and return WAV bytes.""" + def _call() -> bytes: + client = genai.Client(api_key=self._api_key) + response = client.models.generate_content( + model="gemini-2.5-flash-preview-tts", + contents=text, + config=genai_types.GenerateContentConfig( + response_modalities=["AUDIO"], + speech_config=genai_types.SpeechConfig( + voice_config=genai_types.VoiceConfig( + prebuilt_voice_config=genai_types.PrebuiltVoiceConfig( + voice_name=voice, + ) + ) + ), + ), + ) + pcm_data = response.candidates[0].content.parts[0].inline_data.data + return pcm_to_wav(pcm_data) + + return await asyncio.to_thread(_call) diff --git a/api/app/outbound/postgres/__init__.py b/api/app/outbound/postgres/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/database.py b/api/app/outbound/postgres/database.py similarity index 92% rename from api/app/database.py rename to api/app/outbound/postgres/database.py index f073a38..5dbb6e0 100644 --- a/api/app/database.py +++ b/api/app/outbound/postgres/database.py @@ -1,7 +1,7 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.orm import DeclarativeBase -from .config import settings +from ...config import settings engine = create_async_engine(settings.database_url) AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) diff --git a/api/app/outbound/postgres/entities/__init__.py b/api/app/outbound/postgres/entities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/models.py b/api/app/outbound/postgres/entities/summarise_job_entity.py similarity index 64% rename from api/app/models.py rename to api/app/outbound/postgres/entities/summarise_job_entity.py index 32675d9..f3d5411 100644 --- a/api/app/models.py +++ b/api/app/outbound/postgres/entities/summarise_job_entity.py @@ -1,31 +1,13 @@ import uuid from datetime import datetime, timezone -from sqlalchemy import String, Text, DateTime, Boolean, ForeignKey +from sqlalchemy import String, Text, DateTime, ForeignKey from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.dialects.postgresql import UUID -from .database import Base +from ..database import Base - -class User(Base): - __tablename__ = "users" - - id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 - ) - email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True) - hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) - is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) - # TODO(email-verification): set to False and require verification once transactional email is implemented - is_email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - default=lambda: datetime.now(timezone.utc), - ) - - -class Job(Base): +class SummariseJobEntity(Base): __tablename__ = "jobs" id: Mapped[uuid.UUID] = mapped_column( diff --git a/api/app/outbound/postgres/entities/user_entity.py b/api/app/outbound/postgres/entities/user_entity.py new file mode 100644 index 0000000..c8539db --- /dev/null +++ b/api/app/outbound/postgres/entities/user_entity.py @@ -0,0 +1,27 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import String, Text, DateTime, Boolean, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID + +from ..database import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True) + hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + # TODO(email-verification): set to False and require verification once transactional email is implemented + is_email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + ) + + diff --git a/api/app/outbound/postgres/repositories/__init__.py b/api/app/outbound/postgres/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/outbound/postgres/repositories/summarise_job_repository.py b/api/app/outbound/postgres/repositories/summarise_job_repository.py new file mode 100644 index 0000000..1b7ebbc --- /dev/null +++ b/api/app/outbound/postgres/repositories/summarise_job_repository.py @@ -0,0 +1,94 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..entities.summarise_job_entity import SummariseJobEntity + + +async def update(db: AsyncSession, job: SummariseJobEntity) -> None: + await db.commit() + + +async def create( + db: AsyncSession, + user_id: uuid.UUID, + source_language: str, + target_language: str, + complexity_level: str, +) -> SummariseJobEntity: + job = SummariseJobEntity( + user_id=user_id, + source_language=source_language, + target_language=target_language, + complexity_level=complexity_level, + ) + db.add(job) + await db.commit() + await db.refresh(job) + return job + + +async def get_by_id(db: AsyncSession, job_id: uuid.UUID) -> SummariseJobEntity | None: + return await db.get(SummariseJobEntity, job_id) + + +async def list_all(db: AsyncSession) -> list[SummariseJobEntity]: + result = await db.execute( + select(SummariseJobEntity).order_by(SummariseJobEntity.created_at.desc()) + ) + 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) + job.error_message = None + await update(db, job) + + +async def save_generated_text( + db: AsyncSession, + job: SummariseJobEntity, + generated_text: str, + input_summary: str, +) -> None: + job.generated_text = generated_text + job.input_summary = input_summary + await update(db, job) + + +async def save_translated_text( + db: AsyncSession, + job: SummariseJobEntity, + translated_text: str, +) -> None: + job.translated_text = translated_text + await update(db, job) + + +async def mark_succeeded(db: AsyncSession, job: SummariseJobEntity, audio_url: str) -> None: + job.status = "succeeded" + job.audio_url = audio_url + job.completed_at = datetime.now(timezone.utc) + await update(db, job) + + +async def mark_failed(db: AsyncSession, job: SummariseJobEntity, error: str) -> None: + job.status = "failed" + job.error_message = error + job.completed_at = datetime.now(timezone.utc) + await update(db, job) diff --git a/api/app/outbound/postgres/repositories/user_repository.py b/api/app/outbound/postgres/repositories/user_repository.py new file mode 100644 index 0000000..cf17b15 --- /dev/null +++ b/api/app/outbound/postgres/repositories/user_repository.py @@ -0,0 +1,17 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..entities.user_entity import User + + +async def create(db: AsyncSession, email: str, hashed_password: str) -> User: + user = User(email=email, hashed_password=hashed_password) + db.add(user) + await db.commit() + await db.refresh(user) + return user + + +async def get_by_email(db: AsyncSession, email: str) -> User | None: + result = await db.execute(select(User).where(User.email == email)) + return result.scalar_one_or_none() diff --git a/api/app/routers/api/generation.py b/api/app/routers/api/generation.py index 5a26797..cf06f26 100644 --- a/api/app/routers/api/generation.py +++ b/api/app/routers/api/generation.py @@ -7,12 +7,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from ...languages import SUPPORTED_LANGUAGES, SUPPORTED_LEVELS from ...auth import verify_token -from ...database import get_db, AsyncSessionLocal -from ...models import Job from ...storage import upload_audio -from ...services import llm, tts, job_repo -from ...services.tts import VOICE_BY_LANGUAGE -from ...services.translate import translate +from ...outbound.postgres.database import get_db, AsyncSessionLocal +from ...outbound.postgres.repositories import summarise_job_repository +from ...outbound.anthropic.anthropic_client import AnthropicClient +from ...outbound.deepl.deepl_client import DeepLClient +from ...outbound.gemini.gemini_client import GeminiClient +from ...config import settings from ... import worker router = APIRouter(prefix="/generate", tags=["api"]) @@ -31,16 +32,20 @@ class GenerationResponse(BaseModel): async def _run_generation(job_id: uuid.UUID, request: GenerationRequest) -> None: + anthropic_client = AnthropicClient.new(settings.anthropic_api_key) + deepl_client = DeepLClient(settings.deepl_api_key) + gemini_client = GeminiClient(settings.gemini_api_key) + async with AsyncSessionLocal() as db: - job = await db.get(Job, job_id) - await job_repo.mark_processing(db, job) + job = await summarise_job_repository.get_by_id(db, job_id) + await summarise_job_repository.mark_processing(db, job) try: language_name = SUPPORTED_LANGUAGES[request.target_language] source_material = "\n\n".join(request.input_texts[:3]) - generated_text = await llm.generate_summary_text( + generated_text = await anthropic_client.generate_summary_text( content_to_summarise=source_material, complexity_level=request.complexity_level, from_language=language_name, @@ -48,26 +53,23 @@ async def _run_generation(job_id: uuid.UUID, request: GenerationRequest) -> None length_preference="200-400 words", ) - await job_repo.save_generated_text( + await summarise_job_repository.save_generated_text( db, job, generated_text, source_material[:500] ) - translated_text = await translate(generated_text, request.source_language) + translated_text = await deepl_client.translate(generated_text, request.source_language) - # Save LLM results before attempting TTS so they're preserved on failure - await job_repo.save_generated_text( - db, job, generated_text - ) + await summarise_job_repository.save_translated_text(db, job, translated_text) - voice = VOICE_BY_LANGUAGE.get(request.target_language, "Kore") - wav_bytes = await tts.generate_audio(generated_text, voice) + voice = gemini_client.get_voice_by_language(request.target_language) + wav_bytes = await gemini_client.generate_audio(generated_text, voice) audio_key = f"audio/{job_id}.wav" upload_audio(audio_key, wav_bytes) - await job_repo.mark_succeeded(db, job, audio_key) + await summarise_job_repository.mark_succeeded(db, job, audio_key) except Exception as exc: - await job_repo.mark_failed(db, job, str(exc)) + await summarise_job_repository.mark_failed(db, job, str(exc)) @router.post("", response_model=GenerationResponse, status_code=202) @@ -89,15 +91,13 @@ async def create_generation_job( f"Supported: {sorted(SUPPORTED_LEVELS)}", ) - job = Job( + job = await summarise_job_repository.create( + db, user_id=uuid.UUID(token_data["sub"]), source_language=request.source_language, target_language=request.target_language, complexity_level=request.complexity_level, ) - db.add(job) - await db.commit() - await db.refresh(job) await worker.enqueue(partial(_run_generation, job.id, request)) diff --git a/api/app/routers/api/jobs.py b/api/app/routers/api/jobs.py index 5be3273..56b9934 100644 --- a/api/app/routers/api/jobs.py +++ b/api/app/routers/api/jobs.py @@ -5,14 +5,13 @@ from functools import partial from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select from ...auth import verify_token -from ...database import get_db, AsyncSessionLocal -from ...models import Job +from ...outbound.postgres.database import get_db, AsyncSessionLocal +from ...outbound.postgres.repositories import summarise_job_repository +from ...outbound.gemini.gemini_client import GeminiClient from ...storage import upload_audio -from ...services import tts, job_repo -from ...services.tts import VOICE_BY_LANGUAGE +from ...config import settings from ... import worker router = APIRouter(prefix="/jobs", dependencies=[Depends(verify_token)]) @@ -53,8 +52,7 @@ async def get_jobs( db: AsyncSession = Depends(get_db), ) -> JobListResponse: try: - result = await db.execute(select(Job).order_by(Job.created_at.desc())) - jobs = result.scalars().all() + jobs = await summarise_job_repository.list_all(db) return {"jobs": jobs} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -70,7 +68,7 @@ async def get_job( except ValueError: raise HTTPException(status_code=400, detail="Invalid job ID format") - job: Job | None = await db.get(Job, uid) + job = await summarise_job_repository.get_by_id(db, uid) if job is None: raise HTTPException(status_code=404, detail="Job not found") @@ -97,20 +95,21 @@ async def get_job( async def _run_regenerate_audio(job_id: uuid.UUID) -> None: + gemini_client = GeminiClient(settings.gemini_api_key) async with AsyncSessionLocal() as db: - job = await db.get(Job, job_id) - await job_repo.mark_processing(db, job) + job = await summarise_job_repository.get_by_id(db, job_id) + await summarise_job_repository.mark_processing(db, job) try: - voice = VOICE_BY_LANGUAGE.get(job.target_language, "Kore") - wav_bytes = await tts.generate_audio(job.generated_text, voice) + voice = gemini_client.get_voice_by_language(job.target_language) + wav_bytes = await gemini_client.generate_audio(job.generated_text, voice) audio_key = f"audio/{job_id}.wav" upload_audio(audio_key, wav_bytes) - await job_repo.mark_succeeded(db, job, audio_key) + await summarise_job_repository.mark_succeeded(db, job, audio_key) except Exception as exc: - await job_repo.mark_failed(db, job, str(exc)) + await summarise_job_repository.mark_failed(db, job, str(exc)) @router.post("/{job_id}/regenerate-audio", status_code=202) @@ -124,7 +123,7 @@ async def regenerate_audio( except ValueError: raise HTTPException(status_code=400, detail="Invalid job ID format") - job: Job | None = await db.get(Job, uid) + job = await summarise_job_repository.get_by_id(db, uid) if job is None: raise HTTPException(status_code=404, detail="Job not found") diff --git a/api/app/routers/api/translate.py b/api/app/routers/api/translate.py index ecf6e1b..50c33da 100644 --- a/api/app/routers/api/translate.py +++ b/api/app/routers/api/translate.py @@ -1,8 +1,9 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel +from ...outbound.deepl.deepl_client import DeepLClient, DEEPL_LANGUAGE_CODES from ...auth import verify_token -from ...services.translate import DEEPL_LANGUAGE_CODES, translate +from ...config import settings router = APIRouter(prefix="/translate", tags=["api", "translate"], @@ -22,12 +23,16 @@ async def translate_text( target_language: str, context: str | None = None, ) -> TranslationResponse: - if target_language not in DEEPL_LANGUAGE_CODES: + deepl_client = DeepLClient(settings.deepl_api_key) + + if not deepl_client.can_translate(target_language): raise HTTPException( status_code=400, detail=f"Unsupported target language '{target_language}'. Supported: {list(DEEPL_LANGUAGE_CODES)}", ) - translated = await translate(text, target_language, context) + + translated = await deepl_client.translate(text, target_language, context) + return TranslationResponse( text=text, target_language=target_language, diff --git a/api/app/routers/auth.py b/api/app/routers/auth.py index fd32dd2..f595869 100644 --- a/api/app/routers/auth.py +++ b/api/app/routers/auth.py @@ -1,12 +1,11 @@ from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, EmailStr -from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from ..auth import create_access_token, hash_password, verify_password -from ..database import get_db -from ..models import User +from ..outbound.postgres.database import get_db +from ..outbound.postgres.repositories import user_repository router = APIRouter(prefix="/auth", tags=["auth"]) @@ -28,14 +27,12 @@ class TokenResponse(BaseModel): @router.post("/register", status_code=status.HTTP_201_CREATED) async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)): - user = User( - email=body.email, - hashed_password=hash_password(body.password), - ) - db.add(user) try: - await db.commit() - await db.refresh(user) + user = await user_repository.create( + db, + email=body.email, + hashed_password=hash_password(body.password), + ) except IntegrityError: await db.rollback() raise HTTPException( @@ -52,8 +49,7 @@ async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)): @router.post("/login", response_model=TokenResponse) async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)): - result = await db.execute(select(User).where(User.email == body.email)) - user = result.scalar_one_or_none() + user = await user_repository.get_by_email(db, body.email) if user is None or not verify_password(body.password, user.hashed_password): raise HTTPException( diff --git a/api/app/routers/media.py b/api/app/routers/media.py index b1829e5..52ad30f 100644 --- a/api/app/routers/media.py +++ b/api/app/routers/media.py @@ -2,13 +2,12 @@ import uuid from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import Response -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from botocore.exceptions import ClientError from ..auth import verify_token -from ..database import get_db -from ..models import Job +from ..outbound.postgres.database import get_db +from ..outbound.postgres.repositories import summarise_job_repository from ..storage import download_audio router = APIRouter(prefix="/media", tags=["media"]) @@ -22,10 +21,7 @@ async def get_media_file( ) -> Response: user_id = uuid.UUID(token_data["sub"]) - result = await db.execute( - select(Job).where(Job.audio_url == filename, Job.user_id == user_id) - ) - job = result.scalar_one_or_none() + 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") diff --git a/api/app/services/job_repo.py b/api/app/services/job_repo.py deleted file mode 100644 index e0852e1..0000000 --- a/api/app/services/job_repo.py +++ /dev/null @@ -1,46 +0,0 @@ -from datetime import datetime, timezone - -from sqlalchemy.ext.asyncio import AsyncSession - -from ..models import Job - - -async def mark_processing(db: AsyncSession, job: Job) -> None: - job.status = "processing" - job.started_at = datetime.now(timezone.utc) - job.error_message = None - await db.commit() - - -async def save_generated_text( - db: AsyncSession, - job: Job, - generated_text: str, - input_summary: str, -) -> None: - job.generated_text = generated_text - job.input_summary = input_summary - await db.commit() - - -async def save_translated_text( - db: AsyncSession, - job: Job, - translated_text: str, -) -> None: - job.translated_text = translated_text - await db.commit() - - -async def mark_succeeded(db: AsyncSession, job: Job, audio_url: str) -> None: - job.status = "succeeded" - job.audio_url = audio_url - job.completed_at = datetime.now(timezone.utc) - await db.commit() - - -async def mark_failed(db: AsyncSession, job: Job, error: str) -> None: - job.status = "failed" - job.error_message = error - job.completed_at = datetime.now(timezone.utc) - await db.commit() diff --git a/api/app/services/llm.py b/api/app/services/llm.py deleted file mode 100644 index fdad6d4..0000000 --- a/api/app/services/llm.py +++ /dev/null @@ -1,75 +0,0 @@ -import asyncio -import anthropic - -from ..config import settings - - -def _create_anthropic_client() -> anthropic.Anthropic: - return anthropic.Anthropic(api_key=settings.anthropic_api_key) - - -def _create_system_prompt_summarise_text( - complexity_level: str, - from_language: str, - to_language: str, - length_preference="200-400 words", -) -> str: - return ( - f"You are a language learning content creator.\n" - f"The user will provide input, you will generate an engaging realistic summary text in {to_language} " - f"at {complexity_level} proficiency level (CEFR scale).\n\n" - f"The text you generate will:\n" - f"- Contain ONLY the generated text in {to_language}.\n" - f"- Be appropriate for a {complexity_level} {to_language} speaker.\n" - f"- Never generate inappropriate (hateful, sexual, violent) content. It is preferable to return no text than to generate such content.\n" - f"- Speak directly to the reader/listener, adopting the tone and style of a semi-formal news reporter or podcaster.\n" - f"- Where appropriate (fluency level, content), use a small number of idiomatic expressions.\n" - f"- Be formatted in markdown with paragraphs and line breaks.\n" - f"- Be {length_preference} long.\n" - f"- Be inspired by the following source material " - f"(but written originally in {from_language}):\n\n" - ) - - -def _create_prompt_summarise_text( - source_material: str, -) -> str: - return ( - f"Source material follows: \n\n" - f"{source_material}" - ) - - -async def generate_summary_text( - content_to_summarise: str, - complexity_level: str, - from_language: str, - to_language: str, - length_preference="200-400 words",) -> str: - """Generate text using Anthropic.""" - def _call() -> str: - client = _create_anthropic_client() - message = client.messages.create( - model="claude-sonnet-4-6", - max_tokens=1024, - messages=[ - { - "role": "system", - "content": _create_system_prompt_summarise_text( - complexity_level=complexity_level, - from_language=from_language, - to_language=to_language, - length_preference=length_preference, - ) - }, - { - "role": "user", - "content": _create_prompt_summarise_text( - content_to_summarise - ) - } - ], - ) - return message.content[0].text - - return await asyncio.to_thread(_call) diff --git a/api/app/services/translate.py b/api/app/services/translate.py deleted file mode 100644 index 77087f9..0000000 --- a/api/app/services/translate.py +++ /dev/null @@ -1,33 +0,0 @@ -import httpx - -from ..config import settings - -DEEPL_API_URL = "https://api-free.deepl.com/v2/translate" - -DEEPL_LANGUAGE_CODES = { - "en": "EN-GB", - "fr": "FR", - "es": "ES", - "it": "IT", - "de": "DE", -} - - -async def translate(text: str, to_language: str, context: str | None = None) -> str: - target_lang_code = DEEPL_LANGUAGE_CODES[to_language] - async with httpx.AsyncClient() as client: - response = await client.post( - DEEPL_API_URL, - headers={ - "Authorization": f"DeepL-Auth-Key {settings.deepl_api_key}", - "Content-Type": "application/json", - }, - json={ - "text": [text], - "target_lang": target_lang_code, - "context": context or None, - }, - ) - response.raise_for_status() - data = response.json() - return data["translations"][0]["text"] diff --git a/api/app/services/tts.py b/api/app/services/tts.py deleted file mode 100644 index c4f42f4..0000000 --- a/api/app/services/tts.py +++ /dev/null @@ -1,39 +0,0 @@ -import asyncio - -from google import genai -from google.genai import types as genai_types - -from ..config import settings -from ..storage import pcm_to_wav - -VOICE_BY_LANGUAGE: dict[str, str] = { - "fr": "Kore", - "es": "Charon", - "it": "Aoede", - "de": "Fenrir", - "en": "Kore", -} - - -async def generate_audio(text: str, voice: str) -> bytes: - """Generate TTS audio and return WAV bytes.""" - def _call() -> bytes: - client = genai.Client(api_key=settings.gemini_api_key) - response = client.models.generate_content( - model="gemini-2.5-flash-preview-tts", - contents=text, - config=genai_types.GenerateContentConfig( - response_modalities=["AUDIO"], - speech_config=genai_types.SpeechConfig( - voice_config=genai_types.VoiceConfig( - prebuilt_voice_config=genai_types.PrebuiltVoiceConfig( - voice_name=voice, - ) - ) - ), - ), - ) - pcm_data = response.candidates[0].content.parts[0].inline_data.data - return pcm_to_wav(pcm_data) - - return await asyncio.to_thread(_call) diff --git a/api/architecture.md b/api/architecture.md new file mode 100644 index 0000000..25633d1 --- /dev/null +++ b/api/architecture.md @@ -0,0 +1,48 @@ +# Language Learning App API Architecture + +This is a HTTP API, written in Python, using the Fastapi framework. + +The code should be organised using both Domain Driven Design and Hexagonal Architecture principles. + +## Domain + +The `domain` directory contains the core logic of my application, not related to specific implementation details. + +This is where all of the logic around the actual language learning lives. + +### Models + +In `app/domain/models` contains the core Domain Entities, i.e. classes that represent objects for core domain processes. + +### Services + +The `app/domain/services` directory contains modules that encapsulate the "orchestration" or "choreography" of other components of the system to achieve complex, domain actions. + +For example: + +- `TextGenerationService` details the step-by-step process of how a series of text is synthesised, audio is generated, parts of speech tagged, timed transcripts generated, etc. which is then used by the learner for language learning. + +## Outbound + +The `app/outbound` directory contains modules that allow this project to communicate with external systems. + +### Repositories and persistence + +This project uses SQLAlchemy for persistence and a postgresql database for storage. This is the same in local, deployed, and test environments. + +Notably the `app/outbound/postgres` directory contains two modules: + +- `entities` contains definitions of tables in the database. These are how the Domain Entities are serialised, but this code specifically is used for serialisation to, and de-serialisation from, persisted data and into Domain Entities from the `models` module. +- `repositories` module contains classes which manage transactions (e.g. CRUD operations) with the psotgresql database, managed through sqlite. Code in the repositories module are what allow Models to be persisted via Entities, allowing all other code to be free of implementation details. + +### API Clients + +Other modules in the wider `app/outbound` directory are about communicating with specific third-party APIs, notably through HTTP APIs authenticated with an authentication token. + +These modules are modelled as Classes. + +Example Api Clients in their own modules are: + +- `AnthropicClient` to communicate with Anthorpic's LLM, i.e. Claude, to generate text and synthesis. +- `GeminiClient` to communicate with Google's Gemini for text-to-speech generation +- `DeepgramClient` for timestamped speech-to-text transcription