Compare commits
No commits in common. "271519204ce513d2ac4308987bcbdb3112b4af74" and "3a07c81359b7f1735e59ee733d6d7ca561e1a6ed" have entirely different histories.
271519204c
...
3a07c81359
38 changed files with 1168 additions and 538 deletions
|
|
@ -6,10 +6,8 @@ from sqlalchemy import pool
|
||||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.outbound.postgres.database import Base
|
from app.database import Base
|
||||||
|
import app.models # noqa: F401 — register all models with Base.metadata
|
||||||
import app.outbound.postgres.entities.summarise_job_entity
|
|
||||||
import app.outbound.postgres.entities.user_entity
|
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from ...config import settings
|
from .config import settings
|
||||||
|
|
||||||
engine = create_async_engine(settings.database_url)
|
engine = create_async_engine(settings.database_url)
|
||||||
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
# TODO: Implement this service, taking the code currently placed in app/routes/api/generation.py
|
|
||||||
|
|
@ -1,13 +1,31 @@
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import String, Text, DateTime, ForeignKey
|
from sqlalchemy import String, Text, DateTime, Boolean, ForeignKey
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
from ..database import Base
|
from .database import Base
|
||||||
|
|
||||||
class SummariseJobEntity(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):
|
||||||
__tablename__ = "jobs"
|
__tablename__ = "jobs"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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"]
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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,13 +7,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ...languages import SUPPORTED_LANGUAGES, SUPPORTED_LEVELS
|
from ...languages import SUPPORTED_LANGUAGES, SUPPORTED_LEVELS
|
||||||
from ...auth import verify_token
|
from ...auth import verify_token
|
||||||
|
from ...database import get_db, AsyncSessionLocal
|
||||||
|
from ...models import Job
|
||||||
from ...storage import upload_audio
|
from ...storage import upload_audio
|
||||||
from ...outbound.postgres.database import get_db, AsyncSessionLocal
|
from ...services import llm, tts, job_repo
|
||||||
from ...outbound.postgres.repositories import summarise_job_repository
|
from ...services.tts import VOICE_BY_LANGUAGE
|
||||||
from ...outbound.anthropic.anthropic_client import AnthropicClient
|
from ...services.translate import translate
|
||||||
from ...outbound.deepl.deepl_client import DeepLClient
|
|
||||||
from ...outbound.gemini.gemini_client import GeminiClient
|
|
||||||
from ...config import settings
|
|
||||||
from ... import worker
|
from ... import worker
|
||||||
|
|
||||||
router = APIRouter(prefix="/generate", tags=["api"])
|
router = APIRouter(prefix="/generate", tags=["api"])
|
||||||
|
|
@ -32,20 +31,16 @@ class GenerationResponse(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
async def _run_generation(job_id: uuid.UUID, request: GenerationRequest) -> None:
|
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:
|
async with AsyncSessionLocal() as db:
|
||||||
job = await summarise_job_repository.get_by_id(db, job_id)
|
job = await db.get(Job, job_id)
|
||||||
await summarise_job_repository.mark_processing(db, job)
|
await job_repo.mark_processing(db, job)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
language_name = SUPPORTED_LANGUAGES[request.target_language]
|
language_name = SUPPORTED_LANGUAGES[request.target_language]
|
||||||
|
|
||||||
source_material = "\n\n".join(request.input_texts[:3])
|
source_material = "\n\n".join(request.input_texts[:3])
|
||||||
|
|
||||||
generated_text = await anthropic_client.generate_summary_text(
|
generated_text = await llm.generate_summary_text(
|
||||||
content_to_summarise=source_material,
|
content_to_summarise=source_material,
|
||||||
complexity_level=request.complexity_level,
|
complexity_level=request.complexity_level,
|
||||||
from_language=language_name,
|
from_language=language_name,
|
||||||
|
|
@ -53,23 +48,26 @@ async def _run_generation(job_id: uuid.UUID, request: GenerationRequest) -> None
|
||||||
length_preference="200-400 words",
|
length_preference="200-400 words",
|
||||||
)
|
)
|
||||||
|
|
||||||
await summarise_job_repository.save_generated_text(
|
await job_repo.save_generated_text(
|
||||||
db, job, generated_text, source_material[:500]
|
db, job, generated_text, source_material[:500]
|
||||||
)
|
)
|
||||||
|
|
||||||
translated_text = await deepl_client.translate(generated_text, request.source_language)
|
translated_text = await translate(generated_text, request.source_language)
|
||||||
|
|
||||||
await summarise_job_repository.save_translated_text(db, job, translated_text)
|
# Save LLM results before attempting TTS so they're preserved on failure
|
||||||
|
await job_repo.save_generated_text(
|
||||||
|
db, job, generated_text
|
||||||
|
)
|
||||||
|
|
||||||
voice = gemini_client.get_voice_by_language(request.target_language)
|
voice = VOICE_BY_LANGUAGE.get(request.target_language, "Kore")
|
||||||
wav_bytes = await gemini_client.generate_audio(generated_text, voice)
|
wav_bytes = await tts.generate_audio(generated_text, voice)
|
||||||
audio_key = f"audio/{job_id}.wav"
|
audio_key = f"audio/{job_id}.wav"
|
||||||
upload_audio(audio_key, wav_bytes)
|
upload_audio(audio_key, wav_bytes)
|
||||||
|
|
||||||
await summarise_job_repository.mark_succeeded(db, job, audio_key)
|
await job_repo.mark_succeeded(db, job, audio_key)
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await summarise_job_repository.mark_failed(db, job, str(exc))
|
await job_repo.mark_failed(db, job, str(exc))
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=GenerationResponse, status_code=202)
|
@router.post("", response_model=GenerationResponse, status_code=202)
|
||||||
|
|
@ -91,13 +89,15 @@ async def create_generation_job(
|
||||||
f"Supported: {sorted(SUPPORTED_LEVELS)}",
|
f"Supported: {sorted(SUPPORTED_LEVELS)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
job = await summarise_job_repository.create(
|
job = Job(
|
||||||
db,
|
|
||||||
user_id=uuid.UUID(token_data["sub"]),
|
user_id=uuid.UUID(token_data["sub"]),
|
||||||
source_language=request.source_language,
|
source_language=request.source_language,
|
||||||
target_language=request.target_language,
|
target_language=request.target_language,
|
||||||
complexity_level=request.complexity_level,
|
complexity_level=request.complexity_level,
|
||||||
)
|
)
|
||||||
|
db.add(job)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(job)
|
||||||
|
|
||||||
await worker.enqueue(partial(_run_generation, job.id, request))
|
await worker.enqueue(partial(_run_generation, job.id, request))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,14 @@ from functools import partial
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from ...auth import verify_token
|
from ...auth import verify_token
|
||||||
from ...outbound.postgres.database import get_db, AsyncSessionLocal
|
from ...database import get_db, AsyncSessionLocal
|
||||||
from ...outbound.postgres.repositories import summarise_job_repository
|
from ...models import Job
|
||||||
from ...outbound.gemini.gemini_client import GeminiClient
|
|
||||||
from ...storage import upload_audio
|
from ...storage import upload_audio
|
||||||
from ...config import settings
|
from ...services import tts, job_repo
|
||||||
|
from ...services.tts import VOICE_BY_LANGUAGE
|
||||||
from ... import worker
|
from ... import worker
|
||||||
|
|
||||||
router = APIRouter(prefix="/jobs", dependencies=[Depends(verify_token)])
|
router = APIRouter(prefix="/jobs", dependencies=[Depends(verify_token)])
|
||||||
|
|
@ -52,7 +53,8 @@ async def get_jobs(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> JobListResponse:
|
) -> JobListResponse:
|
||||||
try:
|
try:
|
||||||
jobs = await summarise_job_repository.list_all(db)
|
result = await db.execute(select(Job).order_by(Job.created_at.desc()))
|
||||||
|
jobs = result.scalars().all()
|
||||||
return {"jobs": jobs}
|
return {"jobs": jobs}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
@ -68,7 +70,7 @@ async def get_job(
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=400, detail="Invalid job ID format")
|
raise HTTPException(status_code=400, detail="Invalid job ID format")
|
||||||
|
|
||||||
job = await summarise_job_repository.get_by_id(db, uid)
|
job: Job | None = await db.get(Job, uid)
|
||||||
if job is None:
|
if job is None:
|
||||||
raise HTTPException(status_code=404, detail="Job not found")
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
|
||||||
|
|
@ -95,21 +97,20 @@ async def get_job(
|
||||||
|
|
||||||
|
|
||||||
async def _run_regenerate_audio(job_id: uuid.UUID) -> None:
|
async def _run_regenerate_audio(job_id: uuid.UUID) -> None:
|
||||||
gemini_client = GeminiClient(settings.gemini_api_key)
|
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
job = await summarise_job_repository.get_by_id(db, job_id)
|
job = await db.get(Job, job_id)
|
||||||
await summarise_job_repository.mark_processing(db, job)
|
await job_repo.mark_processing(db, job)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
voice = gemini_client.get_voice_by_language(job.target_language)
|
voice = VOICE_BY_LANGUAGE.get(job.target_language, "Kore")
|
||||||
wav_bytes = await gemini_client.generate_audio(job.generated_text, voice)
|
wav_bytes = await tts.generate_audio(job.generated_text, voice)
|
||||||
audio_key = f"audio/{job_id}.wav"
|
audio_key = f"audio/{job_id}.wav"
|
||||||
upload_audio(audio_key, wav_bytes)
|
upload_audio(audio_key, wav_bytes)
|
||||||
|
|
||||||
await summarise_job_repository.mark_succeeded(db, job, audio_key)
|
await job_repo.mark_succeeded(db, job, audio_key)
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await summarise_job_repository.mark_failed(db, job, str(exc))
|
await job_repo.mark_failed(db, job, str(exc))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{job_id}/regenerate-audio", status_code=202)
|
@router.post("/{job_id}/regenerate-audio", status_code=202)
|
||||||
|
|
@ -123,7 +124,7 @@ async def regenerate_audio(
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=400, detail="Invalid job ID format")
|
raise HTTPException(status_code=400, detail="Invalid job ID format")
|
||||||
|
|
||||||
job = await summarise_job_repository.get_by_id(db, uid)
|
job: Job | None = await db.get(Job, uid)
|
||||||
if job is None:
|
if job is None:
|
||||||
raise HTTPException(status_code=404, detail="Job not found")
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from ...outbound.deepl.deepl_client import DeepLClient, DEEPL_LANGUAGE_CODES
|
|
||||||
from ...auth import verify_token
|
from ...auth import verify_token
|
||||||
from ...config import settings
|
from ...services.translate import DEEPL_LANGUAGE_CODES, translate
|
||||||
|
|
||||||
router = APIRouter(prefix="/translate",
|
router = APIRouter(prefix="/translate",
|
||||||
tags=["api", "translate"],
|
tags=["api", "translate"],
|
||||||
|
|
@ -23,16 +22,12 @@ async def translate_text(
|
||||||
target_language: str,
|
target_language: str,
|
||||||
context: str | None = None,
|
context: str | None = None,
|
||||||
) -> TranslationResponse:
|
) -> TranslationResponse:
|
||||||
deepl_client = DeepLClient(settings.deepl_api_key)
|
if target_language not in DEEPL_LANGUAGE_CODES:
|
||||||
|
|
||||||
if not deepl_client.can_translate(target_language):
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Unsupported target language '{target_language}'. Supported: {list(DEEPL_LANGUAGE_CODES)}",
|
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(
|
return TranslationResponse(
|
||||||
text=text,
|
text=text,
|
||||||
target_language=target_language,
|
target_language=target_language,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..auth import create_access_token, hash_password, verify_password
|
from ..auth import create_access_token, hash_password, verify_password
|
||||||
from ..outbound.postgres.database import get_db
|
from ..database import get_db
|
||||||
from ..outbound.postgres.repositories import user_repository
|
from ..models import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
@ -27,12 +28,14 @@ class TokenResponse(BaseModel):
|
||||||
|
|
||||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||||
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
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:
|
try:
|
||||||
user = await user_repository.create(
|
await db.commit()
|
||||||
db,
|
await db.refresh(user)
|
||||||
email=body.email,
|
|
||||||
hashed_password=hash_password(body.password),
|
|
||||||
)
|
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -49,7 +52,8 @@ async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
user = await user_repository.get_by_email(db, body.email)
|
result = await db.execute(select(User).where(User.email == body.email))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if user is None or not verify_password(body.password, user.hashed_password):
|
if user is None or not verify_password(body.password, user.hashed_password):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@ import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from botocore.exceptions import ClientError
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
from ..auth import verify_token
|
from ..auth import verify_token
|
||||||
from ..outbound.postgres.database import get_db
|
from ..database import get_db
|
||||||
from ..outbound.postgres.repositories import summarise_job_repository
|
from ..models import Job
|
||||||
from ..storage import download_audio
|
from ..storage import download_audio
|
||||||
|
|
||||||
router = APIRouter(prefix="/media", tags=["media"])
|
router = APIRouter(prefix="/media", tags=["media"])
|
||||||
|
|
@ -21,7 +22,10 @@ async def get_media_file(
|
||||||
) -> Response:
|
) -> Response:
|
||||||
user_id = uuid.UUID(token_data["sub"])
|
user_id = uuid.UUID(token_data["sub"])
|
||||||
|
|
||||||
job = await summarise_job_repository.get_by_audio_url_and_user(db, filename, user_id)
|
result = await db.execute(
|
||||||
|
select(Job).where(Job.audio_url == filename, Job.user_id == user_id)
|
||||||
|
)
|
||||||
|
job = result.scalar_one_or_none()
|
||||||
if job is None:
|
if job is None:
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
|
|
|
||||||
46
api/app/services/job_repo.py
Normal file
46
api/app/services/job_repo.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
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()
|
||||||
75
api/app/services/llm.py
Normal file
75
api/app/services/llm.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
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)
|
||||||
33
api/app/services/translate.py
Normal file
33
api/app/services/translate.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
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"]
|
||||||
39
api/app/services/tts.py
Normal file
39
api/app/services/tts.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
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)
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
# 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,14 +1,9 @@
|
||||||
import type { ApiClient } from './client/types.gen.ts';
|
|
||||||
|
|
||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
interface Locals {
|
// interface Locals {}
|
||||||
apiClient?: ApiClient;
|
|
||||||
authToken: string | null;
|
|
||||||
}
|
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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 { PUBLIC_API_BASE_URL } from '$env/static/public'
|
||||||
import { client } from '../client/client.gen.ts';
|
import { createClient } from '../client/client'
|
||||||
client.setConfig({ baseUrl: PUBLIC_API_BASE_URL });
|
|
||||||
export { client };
|
export const apiClient = createClient({ baseUrl: PUBLIC_API_BASE_URL })
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,39 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
<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>
|
|
||||||
5
frontend/src/routes/login/+server.ts
Normal file
5
frontend/src/routes/login/+server.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
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