Compare commits

..

2 Commits

Author SHA1 Message Date
Matteo Rosati
a9e23d94b1 basic auth 2026-02-18 18:02:22 +01:00
Matteo Rosati
39eb4f4f01 optimize db connection 2026-02-18 16:39:41 +01:00
10 changed files with 194 additions and 18 deletions

37
.dockerignore Normal file
View File

@@ -0,0 +1,37 @@
# ── Python runtime artifacts ──────────────────────────────────────────────────
.venv/
__pycache__/
*.py[cod]
*.pyo
.ruff_cache/
*.egg-info/
dist/
build/
# ── Secrets & credentials (mount as volumes or inject via env at runtime) ─────
.env
credentials.json
# ── OS / editor noise ─────────────────────────────────────────────────────────
.DS_Store
.zed/
# ── VCS ───────────────────────────────────────────────────────────────────────
.git/
.gitignore
# ── Analysis / data files (not needed at runtime) ─────────────────────────────
domande/
domande.csv
domande.xlsx
risposte/
risposte.md
parse_questions.py
# ── Package-manager files (pip uses requirements.txt inside the image) ─────────
pyproject.toml
uv.lock
# ── Docker files themselves ────────────────────────────────────────────────────
Dockerfile
.dockerignore

3
.env.dist Normal file
View File

@@ -0,0 +1,3 @@
GOOGLE_APPLICATION_CREDENTIALS=credentials.json
AUTH_USER=
PASSWORD=

55
Dockerfile Normal file
View File

@@ -0,0 +1,55 @@
# ─── Stage 1: Builder ────────────────────────────────────────────────────────
FROM python:3.13-slim AS builder
# Native build tools required by packages with C extensions
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
libffi-dev \
libssl-dev \
make \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY requirements.txt .
# Install all dependencies into an isolated prefix so the runtime stage
# can copy them without pulling in the build toolchain.
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ─── Stage 2: Runtime ────────────────────────────────────────────────────────
FROM python:3.13-slim AS runtime
# Copy compiled packages from the builder — no build tools in the final image
COPY --from=builder /install /usr/local
WORKDIR /app
# Entrypoint: materialises GOOGLE_APPLICATION_CREDENTIALS_JSON → temp file
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Application source
COPY app.py chain.py db.py ./
COPY models/ models/
COPY templates/ templates/
COPY static/ static/
# Prompt templates read at runtime by chain.py
COPY prompt.md question_rewrite_prompt.md ./
# Initial database (mount a named volume here for persistence across restarts)
COPY example.db .
# PORT is read by the __main__ entry-point and by the CMD below.
# Override at runtime with: docker run -e PORT=9090 ...
ENV PORT=8080
EXPOSE 8080
ENTRYPOINT ["/entrypoint.sh"]
# Production command: `fastapi run` wraps uvicorn without --reload.
CMD ["sh", "-c", "exec fastapi run app.py --host 0.0.0.0 --port ${PORT}"]

52
app.py
View File

@@ -1,22 +1,53 @@
import json import json
import os
import secrets
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, WebSocket from dotenv import load_dotenv
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import create_async_engine
from chain import RagChain from chain import RagChain
from db import DB from db import DB
app = FastAPI() load_dotenv()
@asynccontextmanager
async def lifespan(app: FastAPI):
engine = create_async_engine("sqlite+aiosqlite:///example.db")
app.state.db = DB(engine)
yield
await engine.dispose()
app = FastAPI(lifespan=lifespan)
security = HTTPBasic()
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)):
correct_username = secrets.compare_digest(credentials.username, os.environ["AUTH_USER"])
correct_password = secrets.compare_digest(
credentials.password, os.environ["PASSWORD"]
)
if not (correct_username and correct_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Unauthorized",
headers={"WWW-Authenticate": "Basic"},
)
@app.get("/") @app.get("/")
def home(request: Request): async def home(request: Request, _: None = Depends(verify_credentials)):
db = DB() db: DB = request.app.state.db
prompts = db.get_prompts() prompts = await db.get_prompts()
return templates.TemplateResponse( return templates.TemplateResponse(
"index.html", {"request": request, "prompts": prompts} "index.html", {"request": request, "prompts": prompts}
@@ -27,6 +58,8 @@ def home(request: Request):
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
await websocket.accept() await websocket.accept()
db: DB = websocket.app.state.db
try: try:
while True: while True:
raw_message = await websocket.receive_text() raw_message = await websocket.receive_text()
@@ -40,9 +73,8 @@ async def websocket_endpoint(websocket: WebSocket):
message = raw_message message = raw_message
config = {} config = {}
db = DB()
prompt_id = int(config.get("prompt_id", 0)) prompt_id = int(config.get("prompt_id", 0))
prompt = db.get_prompt_by_id(prompt_id) prompt = await db.get_prompt_by_id(prompt_id)
if prompt is None: if prompt is None:
await websocket.send_json( await websocket.send_json(
@@ -74,3 +106,9 @@ async def websocket_endpoint(websocket: WebSocket):
await websocket.send_json({"type": "end"}) await websocket.send_json({"type": "end"})
except Exception: except Exception:
pass pass
if __name__ == "__main__":
import uvicorn
uvicorn.run("app:app", host="0.0.0.0", port=int(os.getenv("PORT", 8080)))

31
db.py
View File

@@ -1,19 +1,28 @@
from typing import List from typing import List
from sqlalchemy import create_engine from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker
from models.orm import Prompt from models.orm import Prompt as PromptORM
from models.validation import Prompt as PromptSchema
class DB: class DB:
def __init__(self, db: str = "sqlite:///example.db"): def __init__(self, engine: AsyncEngine):
self.engine = create_engine(db, connect_args={"check_same_thread": False}) self._session_factory = async_sessionmaker(engine, expire_on_commit=False)
def get_prompts(self) -> List[Prompt]: async def get_prompts(self) -> List[PromptSchema]:
with Session(self.engine) as session: async with self._session_factory() as session:
return session.query(Prompt).all() result = await session.execute(select(PromptORM))
prompts = result.scalars().all()
return [PromptSchema(id=p.id, name=p.name, text=p.text) for p in prompts]
def get_prompt_by_id(self, prompt_id: int) -> Prompt | None: async def get_prompt_by_id(self, prompt_id: int) -> PromptSchema | None:
with Session(self.engine) as session: async with self._session_factory() as session:
return session.query(Prompt).filter(Prompt.id == prompt_id).first() result = await session.execute(
select(PromptORM).where(PromptORM.id == prompt_id)
)
prompt = result.scalar_one_or_none()
if prompt is None:
return None
return PromptSchema(id=prompt.id, name=prompt.name, text=prompt.text)

16
entrypoint.sh Normal file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
set -e
# If the credentials JSON is provided as an env var, materialise it as a
# temporary file and point GOOGLE_APPLICATION_CREDENTIALS at it.
# This avoids baking sensitive credentials into the image.
#
# Usage:
# docker run -e GOOGLE_APPLICATION_CREDENTIALS_JSON="$(cat credentials.json)" ...
if [ -n "$GOOGLE_APPLICATION_CREDENTIALS_JSON" ]; then
CREDS_FILE="$(mktemp /tmp/gcloud-credentials-XXXXXX.json)"
printf '%s' "$GOOGLE_APPLICATION_CREDENTIALS_JSON" > "$CREDS_FILE"
export GOOGLE_APPLICATION_CREDENTIALS="$CREDS_FILE"
fi
exec "$@"

View File

@@ -2,5 +2,6 @@ from pydantic import BaseModel
class Prompt(BaseModel): class Prompt(BaseModel):
id: int
name: str name: str
text: str text: str

View File

@@ -18,6 +18,8 @@ dependencies = [
"fastapi[standard]", "fastapi[standard]",
"pydantic>=2.12.5", "pydantic>=2.12.5",
"sqlalchemy>=2.0.46", "sqlalchemy>=2.0.46",
"aiosqlite>=0.22.1",
"greenlet>=3.3.1",
] ]
[dependency-groups] [dependency-groups]

View File

@@ -1,6 +1,7 @@
aiohappyeyeballs==2.6.1 aiohappyeyeballs==2.6.1
aiohttp==3.13.3 aiohttp==3.13.3
aiosignal==1.4.0 aiosignal==1.4.0
aiosqlite==0.22.1
annotated-doc==0.0.4 annotated-doc==0.0.4
annotated-types==0.7.0 annotated-types==0.7.0
anyio==4.12.1 anyio==4.12.1
@@ -38,6 +39,7 @@ google-crc32c==1.8.0
google-genai==1.63.0 google-genai==1.63.0
google-resumable-media==2.8.0 google-resumable-media==2.8.0
googleapis-common-protos==1.72.0 googleapis-common-protos==1.72.0
greenlet==3.3.1
grpc-google-iam-v1==0.14.3 grpc-google-iam-v1==0.14.3
grpcio==1.78.0 grpcio==1.78.0
grpcio-status==1.78.0 grpcio-status==1.78.0

13
uv.lock generated
View File

@@ -95,13 +95,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
] ]
[[package]]
name = "aiosqlite"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
]
[[package]] [[package]]
name = "akern" name = "akern"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" },
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "google-cloud-discoveryengine" }, { name = "google-cloud-discoveryengine" },
{ name = "greenlet" },
{ name = "langchain" }, { name = "langchain" },
{ name = "langchain-community" }, { name = "langchain-community" },
{ name = "langchain-core" }, { name = "langchain-core" },
@@ -121,9 +132,11 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiosqlite", specifier = ">=0.22.1" },
{ name = "fastapi", specifier = ">=0.129.0" }, { name = "fastapi", specifier = ">=0.129.0" },
{ name = "fastapi", extras = ["standard"] }, { name = "fastapi", extras = ["standard"] },
{ name = "google-cloud-discoveryengine", specifier = ">=0.17.0" }, { name = "google-cloud-discoveryengine", specifier = ">=0.17.0" },
{ name = "greenlet", specifier = ">=3.3.1" },
{ name = "langchain", specifier = ">=1.2.10" }, { name = "langchain", specifier = ">=1.2.10" },
{ name = "langchain-community", specifier = ">=0.4.1" }, { name = "langchain-community", specifier = ">=0.4.1" },
{ name = "langchain-core", specifier = ">=1.2.13" }, { name = "langchain-core", specifier = ">=1.2.13" },