Skip to content

Commit 3a7035f

Browse files
authored
Merge branch 'main' into validation-layer
2 parents 8396a3a + 1f9c078 commit 3a7035f

12 files changed

Lines changed: 1225 additions & 88 deletions

File tree

Makefile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: help build up down logs shell exec pull-model test clean fireform
1+
.PHONY: help build up down logs shell exec pull-model test clean fireform logs-app logs-ollama logs-frontend super-clean
22

33
help:
44
@printf '%s\n' \
@@ -16,6 +16,9 @@ help:
1616
@echo "make up - Start all containers"
1717
@echo "make down - Stop all containers"
1818
@echo "make logs - View container logs"
19+
@echo "make logs-app - View API container logs"
20+
@echo "make logs-frontend - View frontend container logs"
21+
@echo "make logs-ollama - View Ollama container logs"
1922
@echo "make shell - Open Python shell in app container"
2023
@echo "make exec - Execute Python script in container"
2124
@echo "make pull-model - Pull Mistral model into Ollama"
@@ -45,6 +48,9 @@ logs-app:
4548
logs-ollama:
4649
docker compose logs -f ollama
4750

51+
logs-frontend:
52+
docker compose logs -f frontend
53+
4854
shell:
4955
docker compose exec app /bin/bash
5056

api/db/repositories.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,14 @@ def create_template(session: Session, template: Template) -> Template:
1111
def get_template(session: Session, template_id: int) -> Template | None:
1212
return session.get(Template, template_id)
1313

14+
15+
def list_templates(session: Session) -> list[Template]:
16+
statement = select(Template).order_by(Template.created_at.desc(), Template.id.desc())
17+
return list(session.exec(statement))
18+
1419
# Forms
1520
def create_form(session: Session, form: FormSubmission) -> FormSubmission:
1621
session.add(form)
1722
session.commit()
1823
session.refresh(form)
19-
return form
24+
return form

api/main.py

Lines changed: 20 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,26 @@
1-
import uuid
2-
from starlette.middleware.base import BaseHTTPMiddleware
3-
from fastapi import FastAPI
4-
from api.routes import templates, forms
5-
from fastapi import Request
6-
from fastapi.responses import JSONResponse
7-
from fastapi.exceptions import RequestValidationError
8-
from starlette.exceptions import HTTPException as StarletteHTTPException
9-
10-
app = FastAPI()
11-
12-
class RequestIDMiddleware(BaseHTTPMiddleware):
13-
async def dispatch(self, request: Request, call_next):
14-
request_id = str(uuid.uuid4())
15-
request.state.request_id = request_id
16-
response = await call_next(request)
17-
response.headers["X-Request-ID"] = request_id
18-
return response
19-
20-
21-
app.add_middleware(RequestIDMiddleware)
1+
import os
222

3+
from fastapi import FastAPI
4+
from fastapi.middleware.cors import CORSMiddleware
235

24-
@app.exception_handler(StarletteHTTPException)
25-
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
26-
return JSONResponse(
27-
status_code=exc.status_code,
28-
content={
29-
"error": {
30-
"type": "HTTPException",
31-
"message": exc.detail,
32-
"details": {}
33-
}
34-
},
35-
)
36-
37-
@app.exception_handler(RequestValidationError)
38-
async def validation_exception_handler(request: Request, exc: RequestValidationError):
39-
formatted_errors = []
40-
41-
for err in exc.errors():
42-
loc = err.get("loc", [])
43-
field = loc[-1] if loc else "unknown"
44-
issue = err.get("msg", "Invalid value")
45-
expected = err.get("type", "")
46-
47-
formatted_errors.append({
48-
"field": field,
49-
"issue": issue,
50-
"expected": expected
51-
})
52-
53-
return JSONResponse(
54-
status_code=422,
55-
content={
56-
"error": {
57-
"type": "ValidationError",
58-
"message": "Invalid request data",
59-
"details": formatted_errors,
60-
}
61-
},
62-
)
6+
from api.routes import forms, templates
637

64-
@app.exception_handler(Exception)
65-
async def general_exception_handler(request: Request, exc: Exception):
66-
return JSONResponse(
67-
status_code=500,
68-
content={
69-
"error": {
70-
"type": "InternalServerError",
71-
"message": str(exc),
72-
"details": {}
73-
}
74-
},
75-
)
8+
app = FastAPI()
769

10+
default_origins = "http://127.0.0.1:5173"
11+
allowed_origins = [
12+
origin.strip()
13+
for origin in os.getenv("FRONTEND_ORIGINS", default_origins).split(",")
14+
if origin.strip()
15+
]
16+
17+
app.add_middleware(
18+
CORSMiddleware,
19+
allow_origins=allowed_origins,
20+
allow_credentials=False,
21+
allow_methods=["*"],
22+
allow_headers=["*"],
23+
)
7724

7825
app.include_router(templates.router)
79-
app.include_router(forms.router)
26+
app.include_router(forms.router)

api/routes/templates.py

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,109 @@
1-
from fastapi import APIRouter, Depends
1+
from datetime import datetime
2+
from pathlib import Path
3+
4+
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
5+
from fastapi.responses import FileResponse
26
from sqlmodel import Session
37
from api.deps import get_db
4-
from api.schemas.templates import TemplateCreate, TemplateResponse
5-
from api.db.repositories import create_template
8+
from api.schemas.templates import (
9+
TemplateCreate,
10+
TemplateResponse,
11+
TemplateUploadResponse,
12+
)
13+
from api.db.repositories import create_template, list_templates
614
from api.db.models import Template
715
from src.controller import Controller
816

917
router = APIRouter(prefix="/templates", tags=["templates"])
18+
PROJECT_ROOT = Path(__file__).resolve().parents[2]
19+
DEFAULT_TEMPLATE_DIR = "src/inputs"
20+
21+
22+
def _resolve_target_directory(directory: str) -> Path:
23+
dir_value = (directory or DEFAULT_TEMPLATE_DIR).strip()
24+
if not dir_value:
25+
raise HTTPException(status_code=400, detail="Directory is required.")
26+
27+
candidate = Path(dir_value)
28+
if not candidate.is_absolute():
29+
candidate = (PROJECT_ROOT / candidate).resolve()
30+
else:
31+
candidate = candidate.resolve()
32+
33+
if candidate != PROJECT_ROOT and PROJECT_ROOT not in candidate.parents:
34+
raise HTTPException(status_code=400, detail="Directory must be inside the project.")
35+
36+
return candidate
37+
38+
39+
def _resolve_project_file(file_path: str) -> Path:
40+
raw_path = (file_path or "").strip()
41+
if not raw_path:
42+
raise HTTPException(status_code=400, detail="Path is required.")
43+
44+
candidate = Path(raw_path)
45+
if not candidate.is_absolute():
46+
candidate = (PROJECT_ROOT / candidate).resolve()
47+
else:
48+
candidate = candidate.resolve()
49+
50+
if candidate != PROJECT_ROOT and PROJECT_ROOT not in candidate.parents:
51+
raise HTTPException(status_code=400, detail="Path must be inside the project.")
52+
53+
return candidate
54+
55+
56+
@router.post("/upload", response_model=TemplateUploadResponse)
57+
async def upload_template_pdf(
58+
file: UploadFile = File(...),
59+
directory: str = Form(DEFAULT_TEMPLATE_DIR),
60+
):
61+
filename = Path(file.filename or "").name
62+
if not filename:
63+
raise HTTPException(status_code=400, detail="A PDF filename is required.")
64+
65+
if not filename.lower().endswith(".pdf"):
66+
raise HTTPException(status_code=400, detail="Only PDF files are supported.")
67+
68+
target_dir = _resolve_target_directory(directory)
69+
target_dir.mkdir(parents=True, exist_ok=True)
70+
71+
target_path = target_dir / filename
72+
if target_path.exists():
73+
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
74+
target_path = target_dir / f"{target_path.stem}_{timestamp}{target_path.suffix}"
75+
76+
content = await file.read()
77+
with target_path.open("wb") as output_file:
78+
output_file.write(content)
79+
80+
return TemplateUploadResponse(
81+
filename=target_path.name,
82+
pdf_path=target_path.relative_to(PROJECT_ROOT).as_posix(),
83+
)
84+
85+
86+
@router.get("", response_model=list[TemplateResponse])
87+
def get_templates(db: Session = Depends(get_db)):
88+
return list_templates(db)
89+
90+
91+
@router.get("/preview")
92+
def preview_template_pdf(path: str = Query(..., description="Project-relative PDF path")):
93+
resolved_path = _resolve_project_file(path)
94+
95+
if not resolved_path.exists() or not resolved_path.is_file():
96+
raise HTTPException(status_code=404, detail="PDF file not found.")
97+
98+
if resolved_path.suffix.lower() != ".pdf":
99+
raise HTTPException(status_code=400, detail="Only PDF files can be previewed.")
100+
101+
return FileResponse(resolved_path, media_type="application/pdf", filename=resolved_path.name)
102+
10103

11104
@router.post("/create", response_model=TemplateResponse)
12105
def create(template: TemplateCreate, db: Session = Depends(get_db)):
13106
controller = Controller()
14107
template_path = controller.create_template(template.pdf_path)
15108
tpl = Template(**template.model_dump(exclude={"pdf_path"}), pdf_path=template_path)
16-
return create_template(db, tpl)
109+
return create_template(db, tpl)

api/schemas/templates.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,9 @@ class TemplateResponse(BaseModel):
1212
fields: dict
1313

1414
class Config:
15-
from_attributes = True
15+
from_attributes = True
16+
17+
18+
class TemplateUploadResponse(BaseModel):
19+
filename: str
20+
pdf_path: str

container-init.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ echo "============================================"
1616
make up
1717
echo "============================================"
1818
echo "Use make down to stop"
19-
echo "Use docker ps to verify, you should see 2 containers:"
19+
echo "Use docker ps to verify, you should see 3 containers:"
2020
echo "\t* fireform-app"
21+
echo "\t* fireform-frontend"
2122
echo "\t* ollama/ollama:latest"
2223
docker ps
2324
echo "============================================"
@@ -26,4 +27,3 @@ echo "============================================"
2627
make pull-model
2728
echo "============================================"
2829
echo "Done"
29-

docker-compose.yml

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,32 @@ services:
2323
depends_on:
2424
ollama:
2525
condition: service_healthy
26+
command: /bin/sh -c "python3 -m api.db.init_db && python3 -m uvicorn api.main:app --host 0.0.0.0 --port 8000"
2627
volumes:
2728
- .:/app
29+
ports:
30+
- "8000:8000"
2831
environment:
2932
- PYTHONUNBUFFERED=1
3033
- PYTHONPATH=/app/src
3134
- OLLAMA_HOST=http://ollama:11434
3235
networks:
3336
- fireform-network
34-
stdin_open: true
35-
tty: true
37+
38+
frontend:
39+
image: python:3.11-slim
40+
container_name: fireform-frontend
41+
working_dir: /app
42+
command: python3 -m http.server 5173 --directory frontend
43+
volumes:
44+
- .:/app
45+
ports:
46+
- "5173:5173"
47+
depends_on:
48+
app:
49+
condition: service_started
50+
networks:
51+
- fireform-network
3652

3753
volumes:
3854
ollama_data:

docs/docker.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
# Docker documentation for FireForm
22

33
## Setup
4-
We will be using 2 different containers:
5-
1. `fireform-app` -> This container will hold the whole project itself.
6-
2. `ollama/ollama:latest` -> This is to deploy ollama, that way it's faster to set up.
4+
We will be using 3 containers:
5+
1. `fireform-app` -> Runs the FastAPI server on `http://127.0.0.1:8000`.
6+
2. `fireform-frontend` -> Serves the frontend on `http://127.0.0.1:5173`.
7+
3. `ollama/ollama:latest` -> Runs Ollama for LLM calls.
78

89
### Initial configuration steps
910
For this I provided a script that can be run to automate the setup.
@@ -46,6 +47,23 @@ make clean # Remove all containers and volumes
4647
```
4748
* You can see this list at any time by running `make help`.
4849

50+
## Running the full stack
51+
52+
```bash
53+
make build
54+
make up
55+
```
56+
57+
Then open:
58+
- Frontend: `http://127.0.0.1:5173`
59+
- API docs: `http://127.0.0.1:8000/docs`
60+
61+
If this is your first run, pull the model once:
62+
63+
```bash
64+
make pull-model
65+
```
66+
4967
## Debugging
5068
For debugging with LLMs it's really useful to attach the logs.
5169
* You can obtain the logs using `make logs` or `docker compose logs`.

0 commit comments

Comments
 (0)