Files
Stirling-PDF/engine/src/app.py
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

820 lines
31 KiB
Python
Raw Normal View History

2026-03-16 11:01:50 +00:00
import json
import mimetypes
import os
import re
import time
import urllib.error
import urllib.request
import uuid
from typing import Literal
from flask import Flask, Response, jsonify, request, send_file, send_from_directory, stream_with_context
from flask_cors import CORS
from pydantic import BaseModel
from werkzeug.exceptions import NotFound
from werkzeug.security import safe_join
import analytics
import models
from ai_generation import generate_field_values
from briefs import _preprocess_intent
from chat_router import classify_chat_route
from config import (
ASSETS_DIR,
FAST_MODEL,
FLASK_DEBUG,
JAVA_BACKEND_API_KEY,
JAVA_REQUEST_TIMEOUT_SECONDS,
OUTPUT_DIR,
SMART_MODEL,
logger,
model_max_tokens,
)
from document_types import detect_document_type
from editing import register_edit_routes
from editing.decisions import answer_conversational_info
from file_processing_agent import ToolCatalogService
from html_pdf_utils import compile_html_to_pdf
from html_utils import inject_theme
from java_client import java_headers, java_url
from llm_utils import run_ai
from pdf_generator import _DEFAULT_TEMPLATES_DIR, PDFGenerator
from pdf_text_editor import convert_pdf_to_text_editor_document
from prompts import (
generate_all_sections_system_prompt,
pdf_qa_system_prompt,
section_fill_system_prompt,
)
from smart_folder_creator import create_smart_folder_config
from storage import load_versions
app = Flask(__name__)
CORS(app)
_tool_catalog_service = ToolCatalogService()
register_edit_routes(app)
@app.before_request
def log_job_request_sequence() -> None:
job_id = request.headers.get("X-Job-Id")
if not job_id:
return
seq = request.headers.get("X-Job-Seq", "?")
total = request.headers.get("X-Job-Total", "?")
logger.info("[HTTP] job_id=%s req=%s/%s %s %s", job_id, seq, total, request.method, request.path)
def _json_body[T: BaseModel](model: type[T], request_type: Literal["GET", "POST"] = "POST") -> T:
if request_type == "GET":
payload = request.args.to_dict()
else:
payload = request.get_json(silent=True)
return model.model_validate(payload or {})
def _java_request_json[T: BaseModel](
method: str,
path: str,
payload: BaseModel | None,
response_model: type[T],
) -> T:
url = java_url(path)
data = None
headers = java_headers()
headers["Content-Type"] = "application/json"
if payload is not None:
payload_data = payload.model_dump(by_alias=True, exclude_none=True)
data = json.dumps(payload_data).encode("utf-8")
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=JAVA_REQUEST_TIMEOUT_SECONDS) as resp:
body = resp.read().decode("utf-8")
parsed = json.loads(body) if body else {}
return response_model.model_validate(parsed)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8") if exc.fp else ""
logger.error("[JAVA] %s %s failed status=%s detail=%s", method, path, exc.code, detail)
raise
def _fetch_ai_session(session_id: str):
auth_header = request.headers.get("Authorization")
api_key = request.headers.get("X-API-KEY")
if auth_header or api_key:
path = f"/api/v1/ai/create/sessions/{session_id}"
elif JAVA_BACKEND_API_KEY:
path = f"/api/v1/ai/create/internal/sessions/{session_id}"
else:
path = f"/api/v1/ai/create/sessions/{session_id}"
return _java_request_json("GET", path, None, models.AISession)
def _update_ai_session(session_id: str, payload: models.JavaUpdateSessionRequest):
return _java_request_json(
"POST", f"/api/v1/ai/create/internal/sessions/{session_id}/update", payload, models.AISession
)
@app.route("/api/intent/check", methods=["POST"])
def intent_check():
payload = _json_body(models.IntentCheckRequest)
prompt = payload.prompt
history = payload.conversation_history
current_pdf_url = payload.current_pdf_url
intent = _preprocess_intent(prompt, history, bool(current_pdf_url))
response = intent.model_copy(update={"doc_type": intent.document_type, "has_pdf": bool(current_pdf_url)})
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
@app.route("/api/chat/route", methods=["POST"])
def chat_route():
payload = _json_body(models.ChatRouteRequest)
decision = classify_chat_route(payload)
return jsonify(decision.model_dump(by_alias=True, exclude_none=True))
@app.route("/api/chat/create-smart-folder", methods=["POST"])
def create_smart_folder():
payload = _json_body(models.SmartFolderCreateRequest)
result = create_smart_folder_config(payload)
return jsonify(result.model_dump(by_alias=True, exclude_none=True))
@app.route("/api/chat/interpret-parameter", methods=["POST"])
def interpret_parameter():
"""Use AI to interpret an ambiguous parameter response during Tier 2 workflow collection."""
payload = _json_body(models.InterpretParameterRequest)
system_prompt = (
"You are a parameter extraction assistant for a PDF tool workflow.\n"
"Given a question asked to the user and the user's response, decide how to interpret it.\n\n"
"Return JSON with these fields:\n"
'- "type": one of "value", "confused", "cancel", "default"\n'
' - "value": the user gave a usable answer, extract it cleanly\n'
' - "confused": the user is asking for help or does not understand\n'
' - "cancel": the user wants to stop or cancel\n'
' - "default": the user is vague; suggest the most sensible default\n'
'- "extracted_value": the clean extracted value (only for type="value" or type="default")\n'
'- "help_message": a short helpful message explaining what was interpreted or what options exist\n\n'
'Be concise. For type="value", extracted_value should be just the raw value (e.g. "English", "3", "DOCX").\n'
'For type="default", extracted_value should be the suggested default value.'
)
user_prompt = (
f"Tool: {payload.tool_name}\nQuestion asked: {payload.question}\nUser response: {payload.user_response}"
)
messages = [
models.ChatMessage(role="system", content=system_prompt),
models.ChatMessage(role="user", content=user_prompt),
]
result = run_ai(
FAST_MODEL,
messages,
models.InterpretParameterResponse,
tag="interpret_parameter",
log_label="interpret-parameter",
)
return jsonify(result.model_dump(by_alias=True, exclude_none=True))
@app.route("/api/chat/infer-tools", methods=["POST"])
def infer_tools():
"""Use AI to infer which PDF tools to apply from a freeform user message."""
payload = _json_body(models.InferToolsRequest)
tools_list = "\n".join(f"- {t}" for t in payload.available_tools)
system_prompt = (
"You are a tool selection assistant for Stirling PDF.\n"
"Given a user message, identify which PDF tools to apply to achieve the user's goal.\n"
"Return tools in the execution order.\n\n"
"Rules:\n"
"- Only return tools with HIGH confidence — leave out anything uncertain\n"
"- Return empty list if the request is a question, ambiguous, or conversational\n"
"- Return empty list if the request is about creating a new document\n"
"- Maximum 4 tools\n\n"
f"Available tools (format: 'id: Display Name'):\n{tools_list}\n\n"
'Return JSON: { "tools": [{"tool_id": "...", "confidence": "high"|"medium"|"low"}], "reason": "..." }\n'
'IMPORTANT: tool_id must be the exact identifier before the colon (e.g. "convert", not "convert: Convert PDF").'
)
messages_list = [
models.ChatMessage(role="system", content=system_prompt),
models.ChatMessage(role="user", content=f"User message: {payload.message}"),
]
result = run_ai(
FAST_MODEL,
messages_list,
models.InferToolsResponse,
tag="infer_tools",
log_label="infer-tools",
)
filtered = models.InferToolsResponse(
tools=[t for t in result.tools if t.confidence == "high"],
reason=result.reason,
)
return jsonify(filtered.model_dump(by_alias=True, exclude_none=True))
@app.route("/api/chat/info", methods=["POST"])
def chat_info():
"""Handle conversational queries without requiring a file or session."""
payload = _json_body(models.ChatInfoRequest)
tool_catalog = ToolCatalogService()
assistant_message = answer_conversational_info(
payload.message,
payload.history,
tool_catalog,
)
response = models.ChatInfoResponse(assistant_message=assistant_message)
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
@app.route("/api/detect_type", methods=["POST"])
def detect_type():
"""
Detect document type from a prompt using a fast AI model.
This endpoint uses a cheap/fast model (FAST_MODEL) for AI classification
to minimize cost and latency.
Request body:
- prompt: The user's document request
- explicitType: If provided, skip detection and return this type
Response:
- docType: The detected type
- confidence: "medium" (AI), "low" (AI)
- method: "explicit" or "ai"
"""
payload = _json_body(models.DetectTypeRequest)
prompt = payload.prompt
explicit_type = payload.explicit_type or ""
# If user explicitly provided a type, skip detection
if explicit_type and explicit_type not in ("other", "document", "miscellaneous", ""):
logger.info("[DETECT] Using explicit doc_type=%s, skipping detection", explicit_type)
response = models.DetectTypeResponse(doc_type=explicit_type, confidence="high", method="explicit")
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
if not prompt:
response = models.DetectTypeResponse(error="Missing prompt for detection")
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 400
# Use fast AI detection (uses FAST_MODEL for cheap classification)
ai_type, confidence = detect_document_type(prompt, confidence_threshold=0.7)
logger.info("[DETECT] Fast AI detected doc_type=%s (confidence=%.2f)", ai_type, confidence)
response = models.DetectTypeResponse(
doc_type=ai_type or "other",
confidence="medium" if confidence >= 0.7 else "low",
method="ai",
)
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
@app.route("/api/pdf/answer", methods=["POST"])
def pdf_answer():
payload = _json_body(models.PdfAnswerRequest)
pdf_url = payload.pdf_url
question = payload.question
if not pdf_url or not question:
response = models.PdfAnswerResponse(error="Missing pdfUrl or question")
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 400
filename = os.path.basename(pdf_url.split("?")[0])
if not filename.lower().endswith(".pdf"):
response = models.PdfAnswerResponse(error="Invalid pdf file")
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 400
pdf_path = safe_join(OUTPUT_DIR, filename)
if pdf_path is None or not os.path.exists(pdf_path):
response = models.PdfAnswerResponse(error="PDF not found")
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 404
doc = convert_pdf_to_text_editor_document(pdf_path)
pages = doc.document.pages if doc else []
snippets: list[str] = []
for page in pages:
for elem in page.text_elements:
text = elem.text
if text:
snippets.append(str(text))
if not snippets:
response = models.PdfAnswerResponse(error="No readable text in PDF")
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 400
# Normalize and limit context
context = " ".join(snippets)
context = " ".join(context.split()) # normalize whitespace
max_context = 10000
if len(context) > max_context:
context = context[:max_context]
model_name = SMART_MODEL
system_prompt = pdf_qa_system_prompt() + "\nReturn JSON matching the provided schema."
user_prompt = f"Question: {question}\n\nPDF text:\n{context}"
messages = [
models.ChatMessage(role="system", content=system_prompt),
models.ChatMessage(role="user", content=user_prompt),
]
response = run_ai(
model_name,
messages,
models.PdfAnswer,
tag="pdf_answer",
max_tokens=220,
)
answer = response.answer.strip()
title_like = re.match(r"^why pdfs|^minimalist|^author:", answer, re.IGNORECASE)
normalized_answer = re.sub(r"\s+", " ", answer).strip().lower()
normalized_context = re.sub(r"\s+", " ", context).strip().lower()
copied_context = bool(normalized_answer) and normalized_answer in normalized_context
if title_like or copied_context:
response = models.PdfAnswerResponse(error="AI answer was invalid or echoed the source text.")
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 500
response = models.PdfAnswerResponse(answer=answer, mode="model")
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
@app.route("/api/create/sessions", methods=["POST"])
def create_session():
"""Create a new AI session via the Java backend."""
create_session_request = _json_body(models.CreateSessionRequest)
# Doc type should ideally come from chat router; fall back to detection if needed
detection_method = "provided"
if create_session_request.doc_type in ("miscellaneous", "other", "document", "unknown", ""):
# Fall back to detection if doc_type not provided by chat router
logger.info("[SESSION] No doc_type provided, running fallback detection")
confidence_threshold = 0.7 # Only accept matches with 70%+ confidence
detected_type, confidence = detect_document_type(
create_session_request.prompt, confidence_threshold=confidence_threshold
)
if confidence >= confidence_threshold:
logger.info(
"[SESSION] Detected doc_type=%s from prompt (confidence=%.2f)",
detected_type,
confidence,
)
create_session_request.doc_type = detected_type
detection_method = "fallback_detection"
else:
logger.info(
"[SESSION] Detection confidence too low (%.2f < %.2f), using 'other'",
confidence,
confidence_threshold,
)
create_session_request.doc_type = "other"
detection_method = "fallback_low_confidence"
else:
logger.info(
"[SESSION] Using doc_type=%s from chat router",
create_session_request.doc_type,
)
result = _java_request_json(
"POST",
"/api/v1/ai/create/sessions",
create_session_request,
models.JavaCreateSessionResponse,
)
session_id = result.session_id
logger.info(
"[SESSION] Created session %s doc_type=%s method=%s",
session_id,
create_session_request.doc_type,
detection_method,
)
# Track session creation in PostHog
safe_doc_type = re.sub(r"[^a-zA-Z0-9_]+", "", (create_session_request.doc_type or "").lower())
has_html_template = (_DEFAULT_TEMPLATES_DIR / f"{safe_doc_type}.html").exists()
analytics.track_session_created(
user_id=session_id,
session_id=session_id,
doc_type=create_session_request.doc_type or "unknown",
template_id=create_session_request.template_id,
has_template=has_html_template,
)
response = models.CreateSessionResponse(
session_id=session_id,
doc_type=create_session_request.doc_type,
detection_method=detection_method,
)
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
@app.route("/api/create/sessions/<session_id>/outline", methods=["POST"])
def update_outline(session_id: str):
"""Update session with approved outline."""
payload = _json_body(models.UpdateOutlineRequest)
_java_request_json(
"POST",
f"/api/v1/ai/create/sessions/{session_id}/outline",
payload,
models.AISession,
)
response = models.SuccessResponse(success=True)
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
@app.route("/api/create/sessions/<session_id>/draft", methods=["POST"])
def update_draft(session_id: str):
"""Update session with approved draft sections."""
payload = _json_body(models.UpdateDraftRequest)
_java_request_json(
"POST",
f"/api/v1/ai/create/sessions/{session_id}/draft",
payload,
models.AISession,
)
response = models.SuccessResponse(success=True)
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
@app.route("/api/create/sessions/<session_id>/template", methods=["POST"])
def update_template(session_id: str):
"""Update session template selection."""
payload = _json_body(models.UpdateTemplateRequest)
_java_request_json(
"POST",
f"/api/v1/ai/create/sessions/{session_id}/template",
payload,
models.AISession,
)
response = models.SuccessResponse(success=True)
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
@app.route("/api/create/sessions/<session_id>/reprompt", methods=["POST"])
def reprompt_session(session_id: str):
"""Update session with new prompt."""
payload = _json_body(models.RepromptRequest)
_java_request_json(
"POST",
f"/api/v1/ai/create/sessions/{session_id}/reprompt",
payload,
models.AISession,
)
response = models.SuccessResponse(success=True)
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
@app.route("/api/create/sessions/<session_id>/stream", methods=["POST"])
def create_stream(session_id: str):
payload = _json_body(models.CreateStreamRequest)
session = _fetch_ai_session(session_id)
prompt = session.prompt_latest or session.prompt_initial or ""
doc_type = session.doc_type or "other"
template_id = session.template_id
outline_text = session.outline_text or ""
outline_filename = session.outline_filename
constraints = session.outline_constraints
draft_sections = session.draft_sections
logo_base64 = payload.theme.logo_base64 if payload.theme else None
theme = payload.theme.css_overrides() if payload.theme else None
logger.info(
"[STREAM] session_id=%s has_theme=%s has_logo=%s",
session_id,
bool(theme),
bool(logo_base64),
)
base_html = payload.base_html or session.polished_html or None
handler = PDFGenerator(
session_id=session_id,
phase=payload.phase,
prompt=prompt,
doc_type=doc_type,
template_id=template_id,
outline_text=outline_text,
outline_filename=outline_filename,
constraints=constraints,
draft_sections=draft_sections,
update_session=_update_ai_session,
theme=theme,
logo_base64=logo_base64,
base_html=base_html,
instructions=payload.additional_instructions,
)
return Response(
stream_with_context(handler.generate()),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.route("/api/create/sessions/<session_id>/fields", methods=["POST"])
def fill_fields(session_id: str):
logger.info("[AI create] fill_fields session_id=%s", session_id)
session = _fetch_ai_session(session_id)
payload = _json_body(models.FillFieldsRequest)
fields = payload.fields
extra_prompt = payload.extra_prompt
if not isinstance(fields, list):
return jsonify({"error": "Fields must be a list"}), 400
prompt = session.prompt_latest or session.prompt_initial or ""
if extra_prompt:
prompt = f"{prompt}\n{extra_prompt}"
doc_type = session.doc_type or "other"
constraints = session.outline_constraints
filled = generate_field_values(prompt, doc_type, fields, constraints)
response = models.FillFieldsResponse(fields=filled)
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
@app.route("/api/generate_section", methods=["POST"])
def generate_section():
"""Generate content for a single section based on a custom prompt."""
payload = _json_body(models.GenerateSectionRequest)
_session_id = payload.session_id
section_label = payload.section_label
section_index = payload.section_index
custom_prompt = payload.custom_prompt
document_prompt = payload.document_prompt
document_type = payload.doc_type
existing_sections = payload.existing_sections
logger.info(
"[AI] generate_section label=%s custom_prompt=%s", section_label, custom_prompt[:50] if custom_prompt else ""
)
# Build context from existing sections (don't truncate - AI needs full context for calculations)
sections_context = "\n".join([f"- {sec.label}: {sec.value}" for sec in existing_sections if sec.value])
system_prompt = section_fill_system_prompt(
document_type,
document_prompt[:500],
sections_context,
section_label,
custom_prompt,
)
messages = [
models.ChatMessage(role="system", content=system_prompt),
models.ChatMessage(role="user", content=f"Generate content for: {section_label}"),
]
parsed = run_ai(
SMART_MODEL,
messages,
models.SectionContent,
tag="generate_section",
max_tokens=500,
)
response = models.GenerateSectionResponse(content=parsed.content.strip(), section_index=section_index)
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
def _run_sections_batch(
batch: list[tuple[int, str]],
document_prompt: str,
additional_prompt: str | None,
system_prompt: str,
) -> dict[int, str]:
sections_list = "\n".join([f"{i + 1}. {label}" for i, label in batch])
user_prompt = (
f"User's document request:\n{document_prompt}\n\n"
"Generate ONLY these sections (leave others unchanged):\n"
f"{sections_list}"
)
if additional_prompt:
user_prompt = f"{user_prompt}\n\nAdditional instructions:\n{additional_prompt}"
messages = [
models.ChatMessage(role="system", content=system_prompt),
models.ChatMessage(role="user", content=user_prompt),
]
parsed = run_ai(
SMART_MODEL,
messages,
models.LLMGenerateAllSectionsResponse,
tag="generate_all_sections",
max_tokens=model_max_tokens(SMART_MODEL),
)
return {section.index - 1: section.value for section in parsed.sections if section.value}
@app.route("/api/generate_all_sections", methods=["POST"])
def generate_all_sections():
"""Generate content for selected sections."""
generation_start = time.time()
payload = _json_body(models.GenerateAllSectionsRequest)
_session_id = payload.session_id
document_prompt = payload.document_prompt
document_type = payload.doc_type
current_sections = payload.sections
only_indices = payload.only_indices # Optional: only generate for these indices
additional_prompt = payload.additional_prompt # Optional: extra instructions from user
# If onlyIndices is provided, only generate for those sections
if only_indices is not None:
indices_to_generate = set(only_indices)
else:
indices_to_generate = set(range(len(current_sections)))
logger.info(
"[AI] generate_all_sections doc_type=%s total_sections=%d generating=%d",
document_type,
len(current_sections),
len(indices_to_generate),
)
if not current_sections:
response = models.GenerateAllSectionsResponse(error="No sections provided")
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 400
if not indices_to_generate:
# Nothing to generate, return sections as-is
response = models.GenerateAllSectionsResponse(sections=current_sections)
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
# Build the section labels list (only for sections we're generating)
section_labels = [section.label for section in current_sections]
# Build prompt only for sections we want to generate
sections_to_generate = [(i, section_labels[i]) for i in sorted(indices_to_generate) if i < len(current_sections)]
system_prompt = generate_all_sections_system_prompt(document_type)
# Batch to avoid hitting model output token limits — each batch is a separate API call.
batch_size = 3
batches = [sections_to_generate[i : i + batch_size] for i in range(0, len(sections_to_generate), batch_size)]
generated_content: dict[int, str] = {}
failed_indices: list[int] = []
for batch in batches:
try:
generated_content.update(_run_sections_batch(batch, document_prompt, additional_prompt, system_prompt))
except Exception as batch_err:
logger.warning(
"[AI] generate_all_sections batch failed (%s), retrying each section individually",
batch_err,
)
for item in batch:
try:
generated_content.update(
_run_sections_batch([item], document_prompt, additional_prompt, system_prompt)
)
except Exception as single_err:
logger.error(
"[AI] generate_all_sections failed for section index=%d label=%r: %s",
item[0],
item[1],
single_err,
)
failed_indices.append(item[0])
# Build final sections list, keeping existing content for non-generated sections
filled_sections = []
for i, section in enumerate(current_sections):
label = section.label
if i in generated_content:
# Use newly generated content
filled_sections.append(models.DraftSection(label=label, value=generated_content[i]))
else:
# Keep existing content
filled_sections.append(models.DraftSection(label=label, value=section.value))
generation_duration = (time.time() - generation_start) * 1000
# Track section generation
analytics.track_event(
user_id=_session_id or "unknown",
event_name="sections_generated",
properties={
"session_id": _session_id,
"doc_type": document_type,
"total_sections": len(current_sections),
"generated_sections": len(indices_to_generate),
"generation_time_ms": generation_duration,
"has_additional_prompt": bool(additional_prompt),
},
)
response = models.GenerateAllSectionsResponse(
sections=filled_sections,
incomplete_section_indices=failed_indices if failed_indices else None,
)
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
@app.route("/output/<path:filename>", methods=["GET"])
def serve_output_file(filename: str):
"""Serve generated PDF files and stored assets."""
mime_type, _ = mimetypes.guess_type(filename)
try:
response = send_from_directory(OUTPUT_DIR, filename, mimetype=mime_type or "application/pdf")
file_size = response.headers.get("Content-Length", "unknown")
logger.info("[SERVE] Serving file=%s size=%s bytes mime=%s", filename, file_size, mime_type)
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "GET"
return response
except NotFound:
logger.warning("[SERVE] File not found: %s", filename)
return jsonify({"error": "File not found"}), 404
@app.route("/api/versions/<user_id>", methods=["GET"])
def list_versions(user_id: str):
response = models.VersionsResponse(versions=load_versions(user_id))
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
@app.route("/api/assets/upload", methods=["POST"])
def upload_asset():
file = request.files.get("file")
if not file:
return jsonify({"error": "Missing file"}), 400
_, ext = os.path.splitext(file.filename or "")
ext = ext.lower()
if ext not in {".png", ".jpg", ".jpeg", ".gif"}:
return jsonify({"error": "Unsupported file type"}), 400
asset_id = f"{uuid.uuid4().hex}{ext}"
output_path = os.path.join(ASSETS_DIR, asset_id)
os.makedirs(ASSETS_DIR, exist_ok=True)
file.save(output_path)
response = models.UploadAssetResponse(
asset_id=asset_id,
asset_url=f"/output/assets/{asset_id}",
)
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
@app.route("/api/templates/previews", methods=["GET"])
def list_preview_templates():
"""Return sorted list of preview template stem names."""
stems = sorted(p.stem for p in _DEFAULT_TEMPLATES_DIR.glob("*_preview.html"))
return jsonify({"templates": stems})
@app.route("/api/templates/preview-html", methods=["GET"])
def get_template_preview_html():
"""Return preview template HTML with default theme injected.
Query param: template — stem name (e.g. 'invoice_preview' or 'invoice_preview.html').
The frontend will override CSS vars via JS for live color preview.
"""
payload = _json_body(models.PreviewTemplateHtmlRequest, "GET")
template_param = payload.template
if not template_param:
return jsonify({"error": "Missing template parameter"}), 400
# Strip .html extension if present
stem = template_param.removesuffix(".html")
if "_preview" not in stem:
return jsonify({"error": "Template must be a preview template (name must contain '_preview')"}), 400
html_path = _DEFAULT_TEMPLATES_DIR / f"{stem}.html"
if not html_path.exists():
return jsonify({"error": f"Template not found: {stem}"}), 404
html = html_path.read_text(encoding="utf-8", errors="replace")
# Inject default theme so vars are defined; frontend will override via JS
html = inject_theme(html, None)
return Response(html, mimetype="text/html")
@app.route("/api/templates/render-preview", methods=["POST"])
def render_preview_to_pdf():
"""Render a preview template to PDF with the given theme.
Body: { template: str, theme: dict | None }
Returns: PDF file download.
"""
payload = _json_body(models.RenderPreviewRequest)
template_param = payload.template
theme = payload.theme.css_overrides() if payload.theme else None
if not template_param:
return jsonify({"error": "Missing template parameter"}), 400
stem = template_param.removesuffix(".html")
if "_preview" not in stem:
return jsonify({"error": "Template must be a preview template"}), 400
html_path = _DEFAULT_TEMPLATES_DIR / f"{stem}.html"
if not html_path.exists():
return jsonify({"error": f"Template not found: {stem}"}), 404
html = html_path.read_text(encoding="utf-8", errors="replace")
html = inject_theme(html, theme)
job_id = f"preview/{uuid.uuid4().hex}"
result = compile_html_to_pdf(html, job_id, log_errors=True)
if result.pdf_path and os.path.exists(result.pdf_path):
return send_file(result.pdf_path, mimetype="application/pdf", as_attachment=True, download_name=f"{stem}.pdf")
return jsonify({"error": result.error or "PDF generation failed"}), 500
@app.route("/health", methods=["GET"])
def health():
response = models.HealthResponse(status="ok", engine="puppeteer")
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5001, debug=FLASK_DEBUG)