initial commit
This commit is contained in:
commit
6bc1efd333
25 changed files with 995 additions and 0 deletions
16
.env.example
Normal file
16
.env.example
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Postgres
|
||||||
|
POSTGRES_USER=langlearn
|
||||||
|
POSTGRES_PASSWORD=changeme
|
||||||
|
POSTGRES_DB=langlearn
|
||||||
|
|
||||||
|
# API
|
||||||
|
API_PORT=8000
|
||||||
|
|
||||||
|
# Auth — sign JWTs with this secret (use a long random string in production)
|
||||||
|
JWT_SECRET=replace-with-a-long-random-secret
|
||||||
|
|
||||||
|
# Anthropic
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
|
||||||
|
# DeepL (https://www.deepl.com/pro-api)
|
||||||
|
DEEPL_API_KEY=your-deepl-api-key-here
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
todo.md
|
||||||
|
.env
|
||||||
31
Language Learning API/Health.yml
Normal file
31
Language Learning API/Health.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
info:
|
||||||
|
name: Health
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: "{{baseUrl}}/health"
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- name: 200 Response
|
||||||
|
description: Successful Response
|
||||||
|
request:
|
||||||
|
url: "{{baseUrl}}/health"
|
||||||
|
method: GET
|
||||||
|
response:
|
||||||
|
status: 200
|
||||||
|
statusText: OK
|
||||||
|
headers:
|
||||||
|
- name: Content-Type
|
||||||
|
value: application/json
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: "{}"
|
||||||
99
Language Learning API/analysis/Analyze Pos.yml
Normal file
99
Language Learning API/analysis/Analyze Pos.yml
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
info:
|
||||||
|
name: Analyze Pos
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
tags:
|
||||||
|
- analysis
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: "{{baseUrl}}/analyze/pos"
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"text": "This is a test",
|
||||||
|
"language": "en"
|
||||||
|
}
|
||||||
|
auth:
|
||||||
|
type: bearer
|
||||||
|
token: "{{token}}"
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
variables:
|
||||||
|
- name: baseUrl
|
||||||
|
value: http://localhost:8000
|
||||||
|
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- name: 200 Response
|
||||||
|
description: Successful Response
|
||||||
|
request:
|
||||||
|
url: "{{baseUrl}}/analyze/pos"
|
||||||
|
method: POST
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"text": "",
|
||||||
|
"language": ""
|
||||||
|
}
|
||||||
|
response:
|
||||||
|
status: 200
|
||||||
|
statusText: OK
|
||||||
|
headers:
|
||||||
|
- name: Content-Type
|
||||||
|
value: application/json
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"language": "",
|
||||||
|
"tokens": [
|
||||||
|
{
|
||||||
|
"text": "",
|
||||||
|
"lemma": "",
|
||||||
|
"pos": "",
|
||||||
|
"tag": "",
|
||||||
|
"dep": "",
|
||||||
|
"is_stop": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
- name: 422 Response
|
||||||
|
description: Validation Error
|
||||||
|
request:
|
||||||
|
url: "{{baseUrl}}/analyze/pos"
|
||||||
|
method: POST
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"text": "",
|
||||||
|
"language": ""
|
||||||
|
}
|
||||||
|
response:
|
||||||
|
status: 422
|
||||||
|
statusText: Unprocessable Entity
|
||||||
|
headers:
|
||||||
|
- name: Content-Type
|
||||||
|
value: application/json
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"detail": [
|
||||||
|
{
|
||||||
|
"loc": [],
|
||||||
|
"msg": "",
|
||||||
|
"type": "",
|
||||||
|
"input": "",
|
||||||
|
"ctx": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
7
Language Learning API/analysis/folder.yml
Normal file
7
Language Learning API/analysis/folder.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
info:
|
||||||
|
name: analysis
|
||||||
|
type: folder
|
||||||
|
seq: 1
|
||||||
|
|
||||||
|
request:
|
||||||
|
auth: inherit
|
||||||
90
Language Learning API/generation/Create Generation Job.yml
Normal file
90
Language Learning API/generation/Create Generation Job.yml
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
info:
|
||||||
|
name: Create Generation Job
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
tags:
|
||||||
|
- generation
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: "{{baseUrl}}/generate"
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"target_language": "",
|
||||||
|
"complexity_level": "",
|
||||||
|
"input_texts": [],
|
||||||
|
"topic": ""
|
||||||
|
}
|
||||||
|
auth:
|
||||||
|
type: bearer
|
||||||
|
token: "{{token}}"
|
||||||
|
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- name: 202 Response
|
||||||
|
description: Successful Response
|
||||||
|
request:
|
||||||
|
url: "{{baseUrl}}/generate"
|
||||||
|
method: POST
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"target_language": "",
|
||||||
|
"complexity_level": "",
|
||||||
|
"input_texts": [],
|
||||||
|
"topic": ""
|
||||||
|
}
|
||||||
|
response:
|
||||||
|
status: 202
|
||||||
|
statusText: Accepted
|
||||||
|
headers:
|
||||||
|
- name: Content-Type
|
||||||
|
value: application/json
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"job_id": ""
|
||||||
|
}
|
||||||
|
- name: 422 Response
|
||||||
|
description: Validation Error
|
||||||
|
request:
|
||||||
|
url: "{{baseUrl}}/generate"
|
||||||
|
method: POST
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"target_language": "",
|
||||||
|
"complexity_level": "",
|
||||||
|
"input_texts": [],
|
||||||
|
"topic": ""
|
||||||
|
}
|
||||||
|
response:
|
||||||
|
status: 422
|
||||||
|
statusText: Unprocessable Entity
|
||||||
|
headers:
|
||||||
|
- name: Content-Type
|
||||||
|
value: application/json
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"detail": [
|
||||||
|
{
|
||||||
|
"loc": [],
|
||||||
|
"msg": "",
|
||||||
|
"type": "",
|
||||||
|
"input": "",
|
||||||
|
"ctx": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
7
Language Learning API/generation/folder.yml
Normal file
7
Language Learning API/generation/folder.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
info:
|
||||||
|
name: generation
|
||||||
|
type: folder
|
||||||
|
seq: 1
|
||||||
|
|
||||||
|
request:
|
||||||
|
auth: inherit
|
||||||
82
Language Learning API/jobs/Get Job.yml
Normal file
82
Language Learning API/jobs/Get Job.yml
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
info:
|
||||||
|
name: Get Job
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
tags:
|
||||||
|
- jobs
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: "{{baseUrl}}/jobs/:job_id"
|
||||||
|
params:
|
||||||
|
- name: job_id
|
||||||
|
value: ""
|
||||||
|
type: path
|
||||||
|
auth:
|
||||||
|
type: bearer
|
||||||
|
token: "{{token}}"
|
||||||
|
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- name: 200 Response
|
||||||
|
description: Successful Response
|
||||||
|
request:
|
||||||
|
url: "{{baseUrl}}/jobs/:job_id"
|
||||||
|
method: GET
|
||||||
|
params:
|
||||||
|
- name: job_id
|
||||||
|
value: ""
|
||||||
|
type: path
|
||||||
|
response:
|
||||||
|
status: 200
|
||||||
|
statusText: OK
|
||||||
|
headers:
|
||||||
|
- name: Content-Type
|
||||||
|
value: application/json
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"id": "",
|
||||||
|
"status": "",
|
||||||
|
"target_language": "",
|
||||||
|
"complexity_level": "",
|
||||||
|
"created_at": "",
|
||||||
|
"generated_text": "",
|
||||||
|
"input_summary": "",
|
||||||
|
"error_message": ""
|
||||||
|
}
|
||||||
|
- name: 422 Response
|
||||||
|
description: Validation Error
|
||||||
|
request:
|
||||||
|
url: "{{baseUrl}}/jobs/:job_id"
|
||||||
|
method: GET
|
||||||
|
params:
|
||||||
|
- name: job_id
|
||||||
|
value: ""
|
||||||
|
type: path
|
||||||
|
response:
|
||||||
|
status: 422
|
||||||
|
statusText: Unprocessable Entity
|
||||||
|
headers:
|
||||||
|
- name: Content-Type
|
||||||
|
value: application/json
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"detail": [
|
||||||
|
{
|
||||||
|
"loc": [],
|
||||||
|
"msg": "",
|
||||||
|
"type": "",
|
||||||
|
"input": "",
|
||||||
|
"ctx": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
82
Language Learning API/jobs/Get jobs.yml
Normal file
82
Language Learning API/jobs/Get jobs.yml
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
info:
|
||||||
|
name: Get jobs
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
tags:
|
||||||
|
- jobs
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: "{{baseUrl}}/jobs/:job_id"
|
||||||
|
params:
|
||||||
|
- name: job_id
|
||||||
|
value: d2130df6-cab2-4407-b35b-45e90dca8555
|
||||||
|
type: path
|
||||||
|
auth:
|
||||||
|
type: bearer
|
||||||
|
token: "{{token}}"
|
||||||
|
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- name: 200 Response
|
||||||
|
description: Successful Response
|
||||||
|
request:
|
||||||
|
url: "{{baseUrl}}/jobs/:job_id"
|
||||||
|
method: GET
|
||||||
|
params:
|
||||||
|
- name: job_id
|
||||||
|
value: ""
|
||||||
|
type: path
|
||||||
|
response:
|
||||||
|
status: 200
|
||||||
|
statusText: OK
|
||||||
|
headers:
|
||||||
|
- name: Content-Type
|
||||||
|
value: application/json
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"id": "",
|
||||||
|
"status": "",
|
||||||
|
"target_language": "",
|
||||||
|
"complexity_level": "",
|
||||||
|
"created_at": "",
|
||||||
|
"generated_text": "",
|
||||||
|
"input_summary": "",
|
||||||
|
"error_message": ""
|
||||||
|
}
|
||||||
|
- name: 422 Response
|
||||||
|
description: Validation Error
|
||||||
|
request:
|
||||||
|
url: "{{baseUrl}}/jobs/:job_id"
|
||||||
|
method: GET
|
||||||
|
params:
|
||||||
|
- name: job_id
|
||||||
|
value: ""
|
||||||
|
type: path
|
||||||
|
response:
|
||||||
|
status: 422
|
||||||
|
statusText: Unprocessable Entity
|
||||||
|
headers:
|
||||||
|
- name: Content-Type
|
||||||
|
value: application/json
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"detail": [
|
||||||
|
{
|
||||||
|
"loc": [],
|
||||||
|
"msg": "",
|
||||||
|
"type": "",
|
||||||
|
"input": "",
|
||||||
|
"ctx": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
7
Language Learning API/jobs/folder.yml
Normal file
7
Language Learning API/jobs/folder.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
info:
|
||||||
|
name: jobs
|
||||||
|
type: folder
|
||||||
|
seq: 1
|
||||||
|
|
||||||
|
request:
|
||||||
|
auth: inherit
|
||||||
31
Language Learning API/opencollection.yml
Normal file
31
Language Learning API/opencollection.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
opencollection: 1.0.0
|
||||||
|
|
||||||
|
info:
|
||||||
|
name: Language Learning API
|
||||||
|
config:
|
||||||
|
proxy:
|
||||||
|
inherit: true
|
||||||
|
config:
|
||||||
|
protocol: http
|
||||||
|
hostname: ""
|
||||||
|
port: ""
|
||||||
|
auth:
|
||||||
|
username: ""
|
||||||
|
password: ""
|
||||||
|
bypassProxy: ""
|
||||||
|
|
||||||
|
request:
|
||||||
|
auth:
|
||||||
|
type: bearer
|
||||||
|
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0In0.sW8dZVeROpNxCHL2HEXym6aDzaobFW17mLPaYbtlyYs
|
||||||
|
variables:
|
||||||
|
- name: baseUrl
|
||||||
|
value: http://localhost:8000
|
||||||
|
- name: token
|
||||||
|
value: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0In0.sW8dZVeROpNxCHL2HEXym6aDzaobFW17mLPaYbtlyYs
|
||||||
|
bundled: false
|
||||||
|
extensions:
|
||||||
|
bruno:
|
||||||
|
ignore:
|
||||||
|
- node_modules
|
||||||
|
- .git
|
||||||
20
Makefile
Normal file
20
Makefile
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
.PHONY: build up down logs shell lock
|
||||||
|
|
||||||
|
build:
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
up:
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
down:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
logs:
|
||||||
|
docker compose logs -f api
|
||||||
|
|
||||||
|
shell:
|
||||||
|
docker compose exec api bash
|
||||||
|
|
||||||
|
# Generate a pinned requirements.txt from pyproject.toml (requires uv installed locally)
|
||||||
|
lock:
|
||||||
|
cd api && uv pip compile pyproject.toml -o requirements.txt
|
||||||
23
api/Dockerfile
Normal file
23
api/Dockerfile
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install uv for fast, reproducible installs
|
||||||
|
RUN pip install --no-cache-dir uv
|
||||||
|
|
||||||
|
# Install Python dependencies from pyproject.toml
|
||||||
|
COPY pyproject.toml .
|
||||||
|
RUN uv pip install --system --no-cache .
|
||||||
|
|
||||||
|
# Download spaCy language models
|
||||||
|
RUN python -m spacy download en_core_web_sm && \
|
||||||
|
python -m spacy download fr_core_news_sm && \
|
||||||
|
python -m spacy download es_core_news_sm && \
|
||||||
|
python -m spacy download it_core_news_sm && \
|
||||||
|
python -m spacy download de_core_news_sm
|
||||||
|
|
||||||
|
# Copy application source
|
||||||
|
COPY app/ ./app/
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
0
api/app/__init__.py
Normal file
0
api/app/__init__.py
Normal file
24
api/app/auth.py
Normal file
24
api/app/auth.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
credentials.credentials,
|
||||||
|
settings.jwt_secret,
|
||||||
|
algorithms=["HS256"],
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired token",
|
||||||
|
)
|
||||||
13
api/app/config.py
Normal file
13
api/app/config.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
database_url: str
|
||||||
|
jwt_secret: str
|
||||||
|
anthropic_api_key: str
|
||||||
|
deepl_api_key: str
|
||||||
|
|
||||||
|
model_config = {"env_file": ".env"}
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
16
api/app/database.py
Normal file
16
api/app/database.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.database_url)
|
||||||
|
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
yield session
|
||||||
25
api/app/main.py
Normal file
25
api/app/main.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from .database import engine, Base
|
||||||
|
from .routers import pos, generation, jobs
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Language Learning API", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.include_router(pos.router)
|
||||||
|
app.include_router(generation.router)
|
||||||
|
app.include_router(jobs.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> dict:
|
||||||
|
return {"status": "ok"}
|
||||||
34
api/app/models.py
Normal file
34
api/app/models.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, DateTime
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
from .database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Job(Base):
|
||||||
|
__tablename__ = "jobs"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||||
|
source_language: Mapped[str] = mapped_column(String(10), nullable=False, default="en")
|
||||||
|
target_language: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||||
|
complexity_level: Mapped[str] = mapped_column(String(5), nullable=False)
|
||||||
|
input_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
generated_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
translated_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
0
api/app/routers/__init__.py
Normal file
0
api/app/routers/__init__.py
Normal file
178
api/app/routers/generation.py
Normal file
178
api/app/routers/generation.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
import anthropic
|
||||||
|
import deepl
|
||||||
|
|
||||||
|
from ..auth import verify_token
|
||||||
|
from ..database import get_db, AsyncSessionLocal
|
||||||
|
from ..models import Job
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/generate", tags=["generation"])
|
||||||
|
|
||||||
|
SUPPORTED_LANGUAGES: dict[str, str] = {
|
||||||
|
"en": "English",
|
||||||
|
"fr": "French",
|
||||||
|
"es": "Spanish",
|
||||||
|
"it": "Italian",
|
||||||
|
"de": "German",
|
||||||
|
}
|
||||||
|
SUPPORTED_LEVELS = {"A1", "A2", "B1", "B2", "C1", "C2"}
|
||||||
|
|
||||||
|
# Maps our language codes to DeepL source/target language codes
|
||||||
|
DEEPL_SOURCE_LANG: dict[str, str] = {
|
||||||
|
"en": "EN",
|
||||||
|
"fr": "FR",
|
||||||
|
"es": "ES",
|
||||||
|
"it": "IT",
|
||||||
|
"de": "DE",
|
||||||
|
}
|
||||||
|
# DeepL target codes (English needs a regional variant)
|
||||||
|
DEEPL_TARGET_LANG: dict[str, str] = {
|
||||||
|
"en": "EN-US",
|
||||||
|
"fr": "FR",
|
||||||
|
"es": "ES",
|
||||||
|
"it": "IT",
|
||||||
|
"de": "DE",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationRequest(BaseModel):
|
||||||
|
target_language: str
|
||||||
|
complexity_level: str
|
||||||
|
input_texts: list[str]
|
||||||
|
topic: str | None = None
|
||||||
|
source_language: str = "en"
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationResponse(BaseModel):
|
||||||
|
job_id: str
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_generation(job_id: uuid.UUID, request: GenerationRequest) -> None:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
job = await db.get(Job, job_id)
|
||||||
|
job.status = "processing"
|
||||||
|
job.started_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from_language = SUPPORTED_LANGUAGES[request.source_language]
|
||||||
|
language_name = SUPPORTED_LANGUAGES[request.target_language]
|
||||||
|
|
||||||
|
# Build a short summary of the input to store (not the full text)
|
||||||
|
topic_part = f"Topic: {request.topic}. " if request.topic else ""
|
||||||
|
combined_preview = " ".join(request.input_texts)[:300]
|
||||||
|
input_summary = (
|
||||||
|
f"{topic_part}Based on {len(request.input_texts)} input text(s): "
|
||||||
|
f"{combined_preview}..."
|
||||||
|
)
|
||||||
|
|
||||||
|
source_material = "\n\n".join(request.input_texts[:3])
|
||||||
|
topic_line = f"\nTopic focus: {request.topic}" if request.topic else ""
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
f"You are a language learning content creator. "
|
||||||
|
f"Using the input provided, you generate engaging realistic text in {language_name} "
|
||||||
|
f"at {request.complexity_level} proficiency level (CEFR scale).\n\n"
|
||||||
|
f"The text should:\n"
|
||||||
|
f"- Be appropriate for a {request.complexity_level} learner\n"
|
||||||
|
f"- Maintain a similar tone to the input text. Where appropriate, use idioms\n"
|
||||||
|
f"- Feel natural and authentic, like content a native speaker would read\n"
|
||||||
|
f"- Be formatted in markdown with paragraphs and line breaks\n"
|
||||||
|
f"- Be 200–400 words long\n"
|
||||||
|
f"- Be inspired by the following source material "
|
||||||
|
f"(but written originally in {language_name}):\n\n"
|
||||||
|
f"{source_material}"
|
||||||
|
f"{topic_line}\n\n"
|
||||||
|
f"Respond with ONLY the generated text in {language_name}, "
|
||||||
|
f"no explanations or translations.\n"
|
||||||
|
f"The 'Topic focus' should be a comma-separated list of up to three topics, in {language_name}."
|
||||||
|
)
|
||||||
|
|
||||||
|
client = anthropic.Anthropic(api_key=settings.anthropic_api_key)
|
||||||
|
|
||||||
|
message = client.messages.create(
|
||||||
|
model="claude-sonnet-4-6",
|
||||||
|
max_tokens=1024,
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
)
|
||||||
|
generated_text = message.content[0].text
|
||||||
|
|
||||||
|
# TODO: Come back to this when DeepL unblock my account for being "high risk"
|
||||||
|
# Translate generated text back into the learner's source language via DeepL
|
||||||
|
# translator = deepl.Translator(settings.deepl_api_key)
|
||||||
|
# translation = translator.translate_text(
|
||||||
|
# generated_text,
|
||||||
|
# source_lang=DEEPL_SOURCE_LANG[request.target_language],
|
||||||
|
# target_lang=DEEPL_TARGET_LANG[request.source_language],
|
||||||
|
#)
|
||||||
|
|
||||||
|
translate_prompt = (
|
||||||
|
f"You are a helpful assistant that translates text. Translate just the previous summary "
|
||||||
|
f"content in {language_name} text you generated based on the input I gave you. Translate "
|
||||||
|
f"it back into {from_language}.\n"
|
||||||
|
f"- Keep the translation as close as possible to the original meaning and tone\n"
|
||||||
|
f"- Send through only the translated text, no explanations or notes\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
translate_message = client.messages.create(
|
||||||
|
model="claude-sonnet-4-6",
|
||||||
|
max_tokens=1024,
|
||||||
|
messages=[
|
||||||
|
{ "role": "user", "content": prompt },
|
||||||
|
{ "role": "assistant", "content": message.content },
|
||||||
|
{ "role": "user", "content": translate_prompt }
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
job.status = "succeeded"
|
||||||
|
job.generated_text = generated_text
|
||||||
|
job.translated_text = translate_message.content[0].text
|
||||||
|
job.input_summary = input_summary[:500]
|
||||||
|
job.completed_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
job.status = "failed"
|
||||||
|
job.error_message = str(exc)
|
||||||
|
job.completed_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=GenerationResponse, status_code=202)
|
||||||
|
async def create_generation_job(
|
||||||
|
request: GenerationRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: dict = Depends(verify_token),
|
||||||
|
) -> GenerationResponse:
|
||||||
|
if request.target_language not in SUPPORTED_LANGUAGES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Unsupported language '{request.target_language}'. "
|
||||||
|
f"Supported: {list(SUPPORTED_LANGUAGES)}",
|
||||||
|
)
|
||||||
|
if request.complexity_level not in SUPPORTED_LEVELS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Unsupported level '{request.complexity_level}'. "
|
||||||
|
f"Supported: {sorted(SUPPORTED_LEVELS)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
job = Job(
|
||||||
|
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)
|
||||||
|
|
||||||
|
background_tasks.add_task(_run_generation, job.id, request)
|
||||||
|
|
||||||
|
return GenerationResponse(job_id=str(job.id))
|
||||||
89
api/app/routers/jobs.py
Normal file
89
api/app/routers/jobs.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
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
|
||||||
|
from ..models import Job
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
||||||
|
|
||||||
|
|
||||||
|
class JobResponse(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
status: str
|
||||||
|
source_language: str
|
||||||
|
target_language: str
|
||||||
|
complexity_level: str
|
||||||
|
created_at: datetime
|
||||||
|
started_at: datetime | None = None
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
# only present on success
|
||||||
|
generated_text: str | None = None
|
||||||
|
translated_text: str | None = None
|
||||||
|
input_summary: str | None = None
|
||||||
|
# only present on failure
|
||||||
|
error_message: str | None = None
|
||||||
|
model_config = { "from_attributes": True }
|
||||||
|
|
||||||
|
class JobSummary(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class JobListResponse(BaseModel):
|
||||||
|
jobs: list[JobSummary]
|
||||||
|
model_config = { "from_attributes": True }
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=JobListResponse)
|
||||||
|
async def get_jobs(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: dict = Depends(verify_token)
|
||||||
|
) -> JobListResponse:
|
||||||
|
try:
|
||||||
|
result = await db.execute(select(Job).order_by(Job.created_at.desc()))
|
||||||
|
jobs = result.scalars().all()
|
||||||
|
return { "jobs": jobs }
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{job_id}", response_model=JobResponse)
|
||||||
|
async def get_job(
|
||||||
|
job_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: dict = Depends(verify_token),
|
||||||
|
) -> JobResponse:
|
||||||
|
try:
|
||||||
|
uid = uuid.UUID(job_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid job ID format")
|
||||||
|
|
||||||
|
job: Job | None = await db.get(Job, uid)
|
||||||
|
if job is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
|
||||||
|
response = JobResponse(
|
||||||
|
id=str(job.id),
|
||||||
|
status=job.status,
|
||||||
|
source_language=job.source_language,
|
||||||
|
target_language=job.target_language,
|
||||||
|
complexity_level=job.complexity_level,
|
||||||
|
created_at=job.created_at,
|
||||||
|
started_at=job.started_at,
|
||||||
|
completed_at=job.completed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
if job.status == "succeeded":
|
||||||
|
response.generated_text = job.generated_text
|
||||||
|
response.translated_text = job.translated_text
|
||||||
|
response.input_summary = job.input_summary
|
||||||
|
elif job.status == "failed":
|
||||||
|
response.error_message = job.error_message
|
||||||
|
|
||||||
|
return response
|
||||||
66
api/app/routers/pos.py
Normal file
66
api/app/routers/pos.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import spacy
|
||||||
|
|
||||||
|
from ..auth import verify_token
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/analyze", tags=["analysis"])
|
||||||
|
|
||||||
|
LANGUAGE_MODELS: dict[str, str] = {
|
||||||
|
"en": "en_core_web_sm",
|
||||||
|
"fr": "fr_core_news_sm",
|
||||||
|
"es": "es_core_news_sm",
|
||||||
|
"it": "it_core_news_sm",
|
||||||
|
"de": "de_core_news_sm",
|
||||||
|
}
|
||||||
|
|
||||||
|
_nlp_cache: dict[str, spacy.Language] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_nlp(language: str) -> spacy.Language:
|
||||||
|
if language not in LANGUAGE_MODELS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Unsupported language '{language}'. Supported: {list(LANGUAGE_MODELS)}",
|
||||||
|
)
|
||||||
|
if language not in _nlp_cache:
|
||||||
|
_nlp_cache[language] = spacy.load(LANGUAGE_MODELS[language])
|
||||||
|
return _nlp_cache[language]
|
||||||
|
|
||||||
|
|
||||||
|
class POSRequest(BaseModel):
|
||||||
|
text: str
|
||||||
|
language: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenInfo(BaseModel):
|
||||||
|
text: str
|
||||||
|
lemma: str
|
||||||
|
pos: str
|
||||||
|
tag: str
|
||||||
|
dep: str
|
||||||
|
is_stop: bool
|
||||||
|
|
||||||
|
|
||||||
|
class POSResponse(BaseModel):
|
||||||
|
language: str
|
||||||
|
tokens: list[TokenInfo]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pos", response_model=POSResponse)
|
||||||
|
def analyze_pos(request: POSRequest, _: dict = Depends(verify_token)) -> POSResponse:
|
||||||
|
nlp = _get_nlp(request.language)
|
||||||
|
doc = nlp(request.text)
|
||||||
|
tokens = [
|
||||||
|
TokenInfo(
|
||||||
|
text=token.text,
|
||||||
|
lemma=token.lemma_,
|
||||||
|
pos=token.pos_,
|
||||||
|
tag=token.tag_,
|
||||||
|
dep=token.dep_,
|
||||||
|
is_stop=token.is_stop,
|
||||||
|
)
|
||||||
|
for token in doc
|
||||||
|
if not token.is_space
|
||||||
|
]
|
||||||
|
return POSResponse(language=request.language, tokens=tokens)
|
||||||
22
api/pyproject.toml
Normal file
22
api/pyproject.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
[project]
|
||||||
|
name = "language-learning-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115.0",
|
||||||
|
"uvicorn[standard]>=0.30.0",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.0",
|
||||||
|
"asyncpg>=0.30.0",
|
||||||
|
"spacy>=3.8.0",
|
||||||
|
"anthropic>=0.40.0",
|
||||||
|
"pyjwt>=2.10.0",
|
||||||
|
"pydantic-settings>=2.0.0",
|
||||||
|
"deepl>=1.18.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["app"]
|
||||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-langlearn}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-langlearn}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-langlearn}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: ./api
|
||||||
|
ports:
|
||||||
|
- "${API_PORT:-8000}:8000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-langlearn}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-langlearn}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
|
||||||
|
DEEPL_API_KEY: ${DEEPL_API_KEY}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
Loading…
Reference in a new issue