Apps: - dwg-rooms: extract room numbers from DWG/DXF - dwg-counting: count symbols in PDF drawings (OpenCV template matching) - contract-check: review PDF contracts against a checklist (Claude vision + Tesseract OCR fallback) - email-drafter: bullet notes → polished Czech/English business emails - invoice-extractor: PDF/image invoice → structured data → Excel - translator: Czech-first translator across 19 languages with tone control - vv-check: find inconsistent unit prices across VV sheets in one workbook - vv-compare: diff original vs new VV files (changes / added / removed) - feature-request: portal users submit ideas + sample files Infrastructure: - LiteLLM gateway with per-app virtual keys + budgets - Langfuse observability - Geist font, shared theme, cross-subdomain back link + theme sync via cookie/URL - Caddy reverse proxy on *.klas.chat
95 lines
2.9 KiB
Python
95 lines
2.9 KiB
Python
"""FastAPI: collect feature requests from portal users.
|
|
|
|
Stores each request to a timestamped folder on disk so the team can review
|
|
later. No LLM, no external calls — pure form handler.
|
|
"""
|
|
import datetime
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
app = FastAPI(title="Feature Request")
|
|
app.add_middleware(CORSMiddleware, allow_origins=["*"],
|
|
allow_methods=["*"], allow_headers=["*"])
|
|
|
|
# Volume-mounted; survives container rebuilds.
|
|
STORAGE = Path(os.getenv("STORAGE_DIR", "/data/requests"))
|
|
STORAGE.mkdir(parents=True, exist_ok=True)
|
|
|
|
MAX_FILE_BYTES = 25 * 1024 * 1024 # 25 MB cap
|
|
|
|
|
|
def _slug(text: str) -> str:
|
|
text = re.sub(r"[^\w\s-]", "", text, flags=re.UNICODE).strip().lower()
|
|
text = re.sub(r"[\s_-]+", "-", text)
|
|
return text[:40] or "request"
|
|
|
|
|
|
@app.get("/")
|
|
async def root():
|
|
return FileResponse("static/index.html")
|
|
|
|
|
|
@app.post("/api/submit")
|
|
async def submit(
|
|
title: str = Form(..., min_length=3, max_length=120),
|
|
description: str = Form(..., min_length=10, max_length=8000),
|
|
name: str = Form("", max_length=120),
|
|
email: str = Form("", max_length=200),
|
|
file: UploadFile | None = File(None),
|
|
):
|
|
title = title.strip()
|
|
description = description.strip()
|
|
if not title or not description:
|
|
raise HTTPException(400, "Vyplňte název a popis")
|
|
|
|
now = datetime.datetime.now()
|
|
folder_name = f"{now.strftime('%Y%m%d_%H%M%S')}_{_slug(title)}_{uuid.uuid4().hex[:8]}"
|
|
folder = STORAGE / folder_name
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
|
|
record = {
|
|
"submitted_at": now.isoformat(timespec="seconds"),
|
|
"title": title,
|
|
"description": description,
|
|
"name": name.strip(),
|
|
"email": email.strip(),
|
|
"attachment": None,
|
|
}
|
|
|
|
if file is not None and file.filename:
|
|
raw = await file.read()
|
|
if len(raw) > MAX_FILE_BYTES:
|
|
raise HTTPException(400, f"Soubor je příliš velký (max {MAX_FILE_BYTES // (1024*1024)} MB)")
|
|
safe_name = re.sub(r"[^\w\.\-]", "_", file.filename)[:200]
|
|
attachment_path = folder / safe_name
|
|
attachment_path.write_bytes(raw)
|
|
record["attachment"] = safe_name
|
|
record["attachment_bytes"] = len(raw)
|
|
record["attachment_content_type"] = file.content_type or ""
|
|
|
|
(folder / "request.json").write_text(
|
|
json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
)
|
|
logger.info("Stored feature request → %s (%s)", folder_name, title)
|
|
return {"ok": True, "id": folder_name}
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|