Compare commits
2 commits
3a07c81359
...
271519204c
| Author | SHA1 | Date | |
|---|---|---|---|
| 271519204c | |||
| 6f5012cb6c |
38 changed files with 538 additions and 1168 deletions
|
|
@ -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)
|
||||
|
|
|
|||
0
api/app/domain/models/__init__.py
Normal file
0
api/app/domain/models/__init__.py
Normal file
25
api/app/domain/models/summarise_job.py
Normal file
25
api/app/domain/models/summarise_job.py
Normal file
|
|
@ -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)
|
||||
0
api/app/domain/services/__init__.py
Normal file
0
api/app/domain/services/__init__.py
Normal file
1
api/app/domain/services/content_generation_service.py
Normal file
1
api/app/domain/services/content_generation_service.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# TODO: Implement this service, taking the code currently placed in app/routes/api/generation.py
|
||||
0
api/app/outbound/__init__.py
Normal file
0
api/app/outbound/__init__.py
Normal file
0
api/app/outbound/anthropic/__init__.py
Normal file
0
api/app/outbound/anthropic/__init__.py
Normal file
74
api/app/outbound/anthropic/anthropic_client.py
Normal file
74
api/app/outbound/anthropic/anthropic_client.py
Normal file
|
|
@ -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)
|
||||
0
api/app/outbound/deepl/__init__.py
Normal file
0
api/app/outbound/deepl/__init__.py
Normal file
38
api/app/outbound/deepl/deepl_client.py
Normal file
38
api/app/outbound/deepl/deepl_client.py
Normal file
|
|
@ -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"]
|
||||
0
api/app/outbound/gemini/__init__.py
Normal file
0
api/app/outbound/gemini/__init__.py
Normal file
52
api/app/outbound/gemini/gemini_client.py
Normal file
52
api/app/outbound/gemini/gemini_client.py
Normal file
|
|
@ -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)
|
||||
0
api/app/outbound/postgres/__init__.py
Normal file
0
api/app/outbound/postgres/__init__.py
Normal file
|
|
@ -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)
|
||||
0
api/app/outbound/postgres/entities/__init__.py
Normal file
0
api/app/outbound/postgres/entities/__init__.py
Normal file
|
|
@ -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(
|
||||
27
api/app/outbound/postgres/entities/user_entity.py
Normal file
27
api/app/outbound/postgres/entities/user_entity.py
Normal file
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
0
api/app/outbound/postgres/repositories/__init__.py
Normal file
0
api/app/outbound/postgres/repositories/__init__.py
Normal file
|
|
@ -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)
|
||||
17
api/app/outbound/postgres/repositories/user_repository.py
Normal file
17
api/app/outbound/postgres/repositories/user_repository.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
try:
|
||||
user = await user_repository.create(
|
||||
db,
|
||||
email=body.email,
|
||||
hashed_password=hash_password(body.password),
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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)
|
||||
48
api/architecture.md
Normal file
48
api/architecture.md
Normal file
|
|
@ -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
|
||||
2
frontend/script/download-openapi.sh
Normal file
2
frontend/script/download-openapi.sh
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Makes a request to localhost:8000/openapi.json and saves the result in ./src/lib/openapi.json
|
||||
curl -o ./src/lib/openapi.json http://localhost:8000/openapi.json
|
||||
7
frontend/src/app.d.ts
vendored
7
frontend/src/app.d.ts
vendored
|
|
@ -1,9 +1,14 @@
|
|||
import type { ApiClient } from './client/types.gen.ts';
|
||||
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
interface Locals {
|
||||
apiClient?: ApiClient;
|
||||
authToken: string | null;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
|
|
|
|||
13
frontend/src/hooks.server.ts
Normal file
13
frontend/src/hooks.server.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { Handle } from '@sveltejs/kit';
|
||||
import { client } from './lib/apiClient.ts';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.apiClient = client;
|
||||
|
||||
const authToken = event.cookies.get('auth_token');
|
||||
event.locals.authToken = authToken || null;
|
||||
|
||||
const response = resolve(event);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { PUBLIC_API_BASE_URL } from '$env/static/public'
|
||||
import { createClient } from '../client/client'
|
||||
|
||||
export const apiClient = createClient({ baseUrl: PUBLIC_API_BASE_URL })
|
||||
import { PUBLIC_API_BASE_URL } from '$env/static/public';
|
||||
import { client } from '../client/client.gen.ts';
|
||||
client.setConfig({ baseUrl: PUBLIC_API_BASE_URL });
|
||||
export { client };
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
39
frontend/src/routes/login/+page.server.ts
Normal file
39
frontend/src/routes/login/+page.server.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { loginAuthLoginPost } from '../../client/sdk.gen.ts';
|
||||
import { redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
|
||||
|
||||
export const load: ServerLoad = async ({ locals }) => {
|
||||
if (locals.authToken) {
|
||||
return redirect(307, '/');
|
||||
}
|
||||
|
||||
return {
|
||||
isLoggedIn: !!locals.authToken
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request, locals, cookies }) => {
|
||||
const formData = await request.formData();
|
||||
const email = formData.get('email') as string;
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
const { response, data } = await loginAuthLoginPost({
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: locals.authToken ? `Bearer ${locals.authToken}` : ''
|
||||
},
|
||||
body: { email, password }
|
||||
});
|
||||
|
||||
if (response.status === 200 && data) {
|
||||
cookies.set('auth_token', data.access_token, {
|
||||
path: '/',
|
||||
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
|
||||
});
|
||||
|
||||
return redirect(307, '/');
|
||||
}
|
||||
|
||||
return { success: response.status === 200, error: response.status !== 200 ? data : null };
|
||||
}
|
||||
} satisfies Actions;
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
const { data }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
{#if data.isLoggedIn}
|
||||
<p>You are logged in</p>
|
||||
{/if}
|
||||
<form method="POST">
|
||||
<div class="field">
|
||||
<label for="email" class="label">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
class="input"
|
||||
type="email"
|
||||
placeholder="e.g john@gmail.com"
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password" class="label">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
class="input"
|
||||
type="password"
|
||||
placeholder="********"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<input type="submit" class="button is-primary" value="Login" />
|
||||
</form>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { createClient } from '../../client/client'
|
||||
import { loginAuthLoginPost } from '../../client/sdk.gen.ts'
|
||||
|
||||
const client = createClient({ baseUrl: 'http://localhost:8000' })
|
||||
loginAuthLoginPost({ client, body: { email: 'test', password: 'test' } })
|
||||
Loading…
Reference in a new issue