Initial portal commit: landing + 9 AI-powered apps

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
This commit is contained in:
Ondřej Glaser
2026-05-13 15:25:04 +02:00
commit 48cef99257
139 changed files with 20171 additions and 0 deletions

440
dwg-counting/main.py Normal file
View File

@@ -0,0 +1,440 @@
"""FastAPI app: upload DWG/DXF/PDF → vision-detect legend → count selected symbols → Excel."""
import asyncio
import logging
import os
import uuid
from pathlib import Path
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from counting import count_template, debug_template
from excel_export import export_to_excel
from pdf_export import render_annotated_pdf
from renderer import crop_region, render
from vision import detect_legend
DEFAULT_FLOOR_INDEX = 0 # MVP: process the first detected floor
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="DWG Symbol Counter")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/dwg-counting"))
WORK_DIR.mkdir(parents=True, exist_ok=True)
jobs: dict[str, dict] = {}
# Persistent action log so the operator can replay what the user did.
ACTION_LOG = WORK_DIR / "action.log"
def _log_action(event: str, **fields):
import datetime, json as _json
line = _json.dumps({
"ts": datetime.datetime.now().isoformat(timespec="seconds"),
"event": event,
**fields,
}, ensure_ascii=False)
try:
with open(ACTION_LOG, "a") as f:
f.write(line + "\n")
except Exception:
pass
@app.get("/")
async def root():
return FileResponse("static/index.html")
@app.post("/api/upload")
async def upload(file: UploadFile = File(...), auto_detect: bool = False):
suffix = Path(file.filename or "").suffix.lower()
if suffix != ".pdf":
raise HTTPException(400, "Podporovaný formát: .pdf")
job_id = str(uuid.uuid4())
job_dir = WORK_DIR / job_id
job_dir.mkdir()
input_path = job_dir / f"input{suffix}"
input_path.write_bytes(await file.read())
logger.info("Job %s: %s (%d bytes)", job_id, file.filename, input_path.stat().st_size)
_log_action("upload", job_id=job_id, filename=file.filename,
size=input_path.stat().st_size, auto_detect=auto_detect)
try:
rendered = await asyncio.to_thread(render, input_path, job_dir)
floors = rendered["floors"]
if not floors:
raise RuntimeError("Z výkresu se nepodařilo nic vyrenderovat")
floor = floors[DEFAULT_FLOOR_INDEX]
png_path = job_dir / floor["png"]
legend_norm = floor.get("legend_norm_bbox")
legend_pixel_box = None
symbols = []
detect_path = png_path
if legend_norm:
from PIL import Image as _Img
full_img = _Img.open(png_path)
W, H = full_img.size
nx0, ny0, nx1, ny1 = legend_norm
# Expand box generously around the LEGENDA text
cx = (nx0 + nx1) / 2
cy = (ny0 + ny1) / 2
# Legend rows extend DOWNWARD from the LEGENDA header text.
# Crop a narrow column starting just above the header.
half_w = 0.10
top_pad = 0.02
below = 0.30
px0 = max(0, int((cx - half_w) * W))
px1 = min(W, int((cx + half_w) * W))
py0 = max(0, int((cy - top_pad) * H))
py1 = min(H, int((cy + below) * H))
legend_crop = job_dir / "legend_area.png"
crop_img = full_img.crop((px0, py0, px1, py1))
crop_img.save(legend_crop, "PNG")
detect_path = legend_crop
legend_pixel_box = (px0, py0, px1 - px0, py1 - py0)
logger.info("Legend area %dx%d ready", px1 - px0, py1 - py0)
if auto_detect:
symbols = await detect_legend(detect_path)
# Symbol bboxes returned by vision are normalized to the image vision saw
# (detect_path), which may be the cropped legend region or the full page.
for s in symbols:
bbox = s.get("bbox") or {}
if all(k in bbox for k in ("x", "y", "w", "h")):
crop_path = job_dir / f"sym_{s['id']}.png"
try:
crop_region(detect_path, bbox, crop_path, pad=1.5, min_px=120)
s["crop_file"] = crop_path.name
except Exception as exc:
logger.warning("Crop failed for %s: %s", s.get("id"), exc)
jobs[job_id] = {
"filename": file.filename,
"png_path": str(png_path),
"job_dir": str(job_dir),
"floors": floors,
"symbols": symbols,
"results": [],
"legend_pixel_box": legend_pixel_box,
"next_user_sym_id": 1,
}
return {"job_id": job_id, "symbols": symbols, "floor_count": len(floors),
"auto_detect": auto_detect}
except Exception as exc:
logger.exception("Job %s failed: %s", job_id, exc)
raise HTTPException(500, str(exc))
@app.get("/api/preview/{job_id}")
async def preview(job_id: str):
if job_id not in jobs:
raise HTTPException(404, "Not found")
return FileResponse(jobs[job_id]["png_path"], media_type="image/png")
@app.get("/api/symbol/{job_id}/{sym_id}")
async def symbol_crop(job_id: str, sym_id: str):
if job_id not in jobs:
raise HTTPException(404, "Not found")
job_dir = Path(jobs[job_id]["job_dir"])
crop_path = job_dir / f"sym_{sym_id}.png"
if not crop_path.exists():
raise HTTPException(404, "Crop not available")
return FileResponse(crop_path, media_type="image/png")
@app.get("/api/legend/{job_id}")
async def legend_image(job_id: str):
"""Return the cropped legend area image (what vision saw)."""
if job_id not in jobs:
raise HTTPException(404, "Not found")
legend_path = Path(jobs[job_id]["job_dir"]) / "legend_area.png"
if not legend_path.exists():
# Fall back to full page if no legend crop
legend_path = Path(jobs[job_id]["png_path"])
return FileResponse(legend_path, media_type="image/png")
class RecropRequest(BaseModel):
bbox: dict # {x, y, w, h} normalized 0-1 relative to the legend image
class CreateSymbolRequest(BaseModel):
bbox: dict # normalized 0-1 relative to the FULL drawing image
description: str
source: str = "drawing" # "drawing" or "legend"
@app.post("/api/symbols/{job_id}")
async def create_user_symbol(job_id: str, req: CreateSymbolRequest):
"""User drew a rectangle on the drawing → create a symbol from that crop."""
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
job_dir = Path(job["job_dir"])
# Pick source image
if req.source == "legend":
src = job_dir / "legend_area.png"
if not src.exists():
src = Path(job["png_path"])
else:
src = Path(job["png_path"])
sym_id = f"user_{job['next_user_sym_id']}"
job["next_user_sym_id"] += 1
crop_path = job_dir / f"sym_{sym_id}.png"
# User's rectangle is exact — no padding, no min size enforcement.
crop_region(src, req.bbox, crop_path, pad=0.0, min_px=0)
sym = {
"id": sym_id,
"description": req.description or sym_id,
"bbox": req.bbox,
"crop_file": crop_path.name,
"user_defined": True,
}
job["symbols"].append(sym)
_log_action("create_symbol", job_id=job_id, sym_id=sym_id,
description=req.description, bbox=req.bbox, source=req.source)
return sym
@app.post("/api/symbols/{job_id}/upload")
async def upload_symbol_image(
job_id: str,
file: UploadFile = File(...),
description: str = "",
):
"""Accept a pre-cropped symbol image as a template, bypassing the
rectangle-drawing UI. Useful when the user has a clean PNG from elsewhere.
"""
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
job_dir = Path(job["job_dir"])
sym_id = f"user_{job['next_user_sym_id']}"
job["next_user_sym_id"] += 1
crop_path = job_dir / f"sym_{sym_id}.png"
raw = await file.read()
crop_path.write_bytes(raw)
# Normalize: ensure RGB on white background (drop alpha so processing
# doesn't see transparency as "not white")
from PIL import Image as _Img
img = _Img.open(crop_path)
if img.mode in ("RGBA", "LA"):
bg = _Img.new("RGB", img.size, (255, 255, 255))
bg.paste(img, mask=img.split()[-1])
bg.save(crop_path, "PNG")
sym = {
"id": sym_id,
"description": (description or file.filename or sym_id).strip(),
"crop_file": crop_path.name,
"user_defined": True,
"uploaded": True,
}
job["symbols"].append(sym)
_log_action("upload_symbol", job_id=job_id, sym_id=sym_id,
description=sym["description"], filename=file.filename,
size=len(raw))
return sym
@app.delete("/api/symbols/{job_id}/{sym_id}")
async def delete_symbol(job_id: str, sym_id: str):
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
job["symbols"] = [s for s in job["symbols"] if s["id"] != sym_id]
crop = Path(job["job_dir"]) / f"sym_{sym_id}.png"
if crop.exists():
crop.unlink()
return {"ok": True}
@app.post("/api/auto-detect/{job_id}")
async def trigger_auto_detect(job_id: str):
"""Run the vision legend detection on demand (optional shortcut)."""
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
job_dir = Path(job["job_dir"])
legend_path = job_dir / "legend_area.png"
detect_path = legend_path if legend_path.exists() else Path(job["png_path"])
found = await detect_legend(detect_path)
for s in found:
bbox = s.get("bbox") or {}
if all(k in bbox for k in ("x", "y", "w", "h")):
crop_path = job_dir / f"sym_{s['id']}.png"
try:
crop_region(detect_path, bbox, crop_path, pad=1.5, min_px=120)
s["crop_file"] = crop_path.name
except Exception as exc:
logger.warning("Crop failed for %s: %s", s.get("id"), exc)
# Replace vision-detected ones (keep user-defined)
job["symbols"] = [s for s in job["symbols"] if s.get("user_defined")] + found
return {"symbols": job["symbols"]}
@app.get("/api/drawing/{job_id}")
async def drawing_image(job_id: str):
"""Return the rendered full drawing image (for the user to crop on)."""
if job_id not in jobs:
raise HTTPException(404, "Not found")
return FileResponse(jobs[job_id]["png_path"], media_type="image/png")
@app.get("/api/debug/{job_id}/{sym_id}")
async def debug_symbol(job_id: str, sym_id: str):
"""Diagnostics about a symbol's template: size, ink, match scores."""
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
crop = Path(job["job_dir"]) / f"sym_{sym_id}.png"
if not crop.exists():
raise HTTPException(404, "Crop not available")
drawing = Path(job["png_path"])
info = await asyncio.to_thread(debug_template, crop, drawing)
return info
@app.get("/api/debug-template/{job_id}/{sym_id}")
async def debug_template_image(job_id: str, sym_id: str):
"""Return the *processed* template (what the matcher actually sees)."""
import cv2
from counting import _prep, _crop_to_content
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
crop = Path(job["job_dir"]) / f"sym_{sym_id}.png"
if not crop.exists():
raise HTTPException(404, "Crop not available")
tmpl = _prep(crop)
tmpl = _crop_to_content(tmpl)
out = Path(job["job_dir"]) / f"sym_{sym_id}_processed.png"
cv2.imwrite(str(out), tmpl)
return FileResponse(str(out), media_type="image/png")
@app.post("/api/symbol/{job_id}/{sym_id}/recrop")
async def recrop_symbol(job_id: str, sym_id: str, req: RecropRequest):
"""Replace a symbol's crop. bbox is normalized 0-1 relative to the FULL
drawing image (which is what the frontend shows in the editor)."""
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
src = Path(job["png_path"])
crop_path = Path(job["job_dir"]) / f"sym_{sym_id}.png"
crop_region(src, req.bbox, crop_path, pad=0.0, min_px=0)
return {"ok": True, "crop_file": crop_path.name}
class CountRequest(BaseModel):
symbol_ids: list[str]
threshold: float | None = None # Override default (0.7); lower = more matches
@app.post("/api/count/{job_id}")
async def count(job_id: str, req: CountRequest):
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
png = Path(job["png_path"])
job_dir = Path(job["job_dir"])
selected = [s for s in job["symbols"] if s["id"] in req.symbol_ids]
# Determine the legend mask box (avoid matching the legend itself).
legend_box = job.get("legend_pixel_box") # set during upload if available
thr = req.threshold if req.threshold is not None else None
def _count_one(sym):
crop = job_dir / f"sym_{sym['id']}.png"
if not crop.exists():
return {"id": sym["id"], "description": sym["description"],
"count": 0, "matches": [], "notes": "no crop"}
try:
kwargs = {"exclude_box": legend_box}
if thr is not None:
kwargs["threshold"] = thr
res = count_template(crop, png, **kwargs)
except Exception as exc:
logger.exception("Counting failed for %s", sym["id"])
return {"id": sym["id"], "description": sym["description"],
"count": 0, "matches": [], "notes": f"error: {exc}"}
return {
"id": sym["id"],
"description": sym["description"],
"count": res["count"],
"matches": res["matches"],
"notes": "" if res["count"] else "žádné shody nenalezeny",
}
# Serialize OpenCV calls — parallel matchTemplate on a 4000px drawing
# blows past 4GB peak memory and OOM-kills the container.
results = []
for s in selected:
r = await asyncio.to_thread(_count_one, s)
results.append(r)
_log_action("count_one", job_id=job_id, sym_id=s["id"],
description=s.get("description"), count=r.get("count"))
job["results"] = list(results)
# Trim matches from API response (keep them server-side for PDF export)
response_results = [{k: v for k, v in r.items() if k != "matches"} | {"count": r["count"]}
for r in job["results"]]
return {"results": response_results}
@app.get("/api/export/{job_id}")
async def export(job_id: str):
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
if not job["results"]:
raise HTTPException(400, "Nejprve spočítejte symboly")
out_path = Path(job["job_dir"]) / "counts.xlsx"
export_to_excel(job["results"], job["filename"] or "drawing", str(out_path))
stem = Path(job["filename"]).stem if job["filename"] else "drawing"
return FileResponse(
str(out_path),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=f"symboly_{stem}.xlsx",
)
@app.get("/api/export-pdf/{job_id}")
async def export_pdf(job_id: str):
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
if not job["results"]:
raise HTTPException(400, "Nejprve spočítejte symboly")
png_path = Path(job["png_path"])
stem = Path(job["filename"]).stem if job["filename"] else "drawing"
out_path = Path(job["job_dir"]) / "annotated.pdf"
await asyncio.to_thread(
render_annotated_pdf, png_path, job["results"], out_path, stem,
)
return FileResponse(
str(out_path),
media_type="application/pdf",
filename=f"vyznaceno_{stem}.pdf",
)
@app.get("/health")
async def health():
return {"status": "ok"}
app.mount("/static", StaticFiles(directory="static"), name="static")