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

6
dwg-rooms/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.env
.git
__pycache__
*.pyc
*.pyo
libredwg-0.12.5/

8
dwg-rooms/.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Copy to .env and fill in values
# LiteLLM proxy (leave LITELLM_API_KEY empty to disable LLM fallback)
LITELLM_BASE_URL=http://host.docker.internal:4000
LITELLM_API_KEY=
# Which LiteLLM model alias to use for the LLM fallback
LLM_MODEL=gpt-4o-mini

38
dwg-rooms/Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
# Stage 1: compile LibreDWG (provides dwg2dxf for DWG → DXF conversion)
FROM debian:bookworm-slim AS libredwg-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
xz-utils \
build-essential \
autoconf \
automake \
libtool \
python3 \
&& rm -rf /var/lib/apt/lists/*
COPY libredwg-0.12.5.tar.xz .
RUN tar xf libredwg-0.12.5.tar.xz \
&& cd libredwg-0.12.5 \
&& ./configure --prefix=/opt/libredwg --disable-shared --disable-bindings \
&& make -j"$(nproc)" \
&& make install \
&& cd .. \
&& rm -rf libredwg-0.12.5 libredwg-0.12.5.tar.xz
# Stage 2: runtime
FROM python:3.12-slim
COPY --from=libredwg-builder /opt/libredwg/bin/dwgread /usr/local/bin/dwgread
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /tmp/dwg-rooms
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,24 @@
services:
dwg-rooms:
build: .
container_name: dwg-rooms
restart: unless-stopped
ports:
- "127.0.0.1:3025:8000"
environment:
LITELLM_BASE_URL: ${LITELLM_BASE_URL:-http://host.docker.internal:4000}
LITELLM_API_KEY: ${LITELLM_API_KEY:-}
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- localai
volumes:
- dwg-rooms-data:/tmp/dwg-rooms
volumes:
dwg-rooms-data:
networks:
localai:
external: true

48
dwg-rooms/excel_export.py Normal file
View File

@@ -0,0 +1,48 @@
"""Export room list to a styled Excel workbook."""
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
def export_to_excel(rooms: list[dict], output_path: str) -> None:
wb = Workbook()
ws = wb.active
ws.title = "Místnosti"
hdr_fill = PatternFill("solid", fgColor="155AEF")
hdr_font = Font(bold=True, color="FFFFFF", size=10)
hdr_align = Alignment(horizontal="center", vertical="center")
row_border = Border(
bottom=Side(style="thin", color="D0D5DC"),
right=Side(style="thin", color="D0D5DC"),
)
alt_fill = PatternFill("solid", fgColor="F9FAFB")
headers = ["Č. místnosti", "Popis / účel", "Zdroj", "Spolehlivost"]
widths = [16, 52, 14, 16]
for col, (hdr, w) in enumerate(zip(headers, widths), 1):
c = ws.cell(row=1, column=col, value=hdr)
c.font = hdr_font
c.fill = hdr_fill
c.alignment = hdr_align
ws.column_dimensions[get_column_letter(col)].width = w
ws.row_dimensions[1].height = 22
ws.freeze_panes = "A2"
for row_num, room in enumerate(sorted(rooms, key=lambda r: str(r.get("room", ""))), 2):
values = [
room.get("room", ""),
room.get("description", ""),
"Pravidla" if room.get("source") == "rule" else "AI",
f"{room.get('confidence', 1.0) * 100:.0f} %",
]
for col, val in enumerate(values, 1):
c = ws.cell(row=row_num, column=col, value=val)
c.alignment = Alignment(vertical="center")
c.border = row_border
if row_num % 2 == 0:
c.fill = alt_fill
wb.save(output_path)

168
dwg-rooms/extractor.py Normal file
View File

@@ -0,0 +1,168 @@
"""Rule-based room extraction from DXF files."""
import math
import re
import ezdxf
# Defaults shown to user; they can remove or replace.
DEFAULT_EXAMPLES = ["č.m. 0301", "01024"]
MEASUREMENT_KW = (
"podlaha:", "stěny:", "strop:", "m2", "", "povrchová", "nátěr",
"penetrační", "vrstva", "odstín", "ral ", "chodníky:", "klenba:",
"portály:", "epoxidový", "beton", "dlažba", "omítka", "obklad",
)
def example_to_regex(example: str) -> re.Pattern | None:
"""
Convert an example like '4-22408' or 'č.m. 0301' into a compiled regex.
- digits become wildcards (\\d, exactly N digits where the example had N digits)
- everything else is matched literally
- an optional trailing letter is allowed (for variants like '0301a')
"""
if not example or not example.strip():
return None
s = example.strip()
parts: list[str] = []
run = 0
for ch in s:
if ch.isdigit():
run += 1
continue
if run:
parts.append(rf"\d{{{run}}}")
run = 0
parts.append(re.escape(ch))
if run:
parts.append(rf"\d{{{run}}}")
pattern = "^(" + "".join(parts) + r"[a-zA-Z]?)$"
try:
return re.compile(pattern, re.IGNORECASE)
except re.error:
return None
def compile_examples(examples: list[str] | None) -> list[re.Pattern]:
items = examples if examples is not None else DEFAULT_EXAMPLES
out = []
for ex in items:
rx = example_to_regex(ex)
if rx is not None:
out.append(rx)
return out
def _clean_mtext(text: str) -> str:
text = re.sub(r"\{\\[^;]*;", "", text)
text = re.sub(r"\\[Pp]", " ", text)
text = text.replace("}", "")
text = re.sub(r"\s*\d+[,\.]\d*\s*m2\s*$", "", text)
return " ".join(text.split()).strip()
def _is_measurement(text: str) -> bool:
t = text.lower()
return any(k in t for k in MEASUREMENT_KW)
def _is_room_marker(text: str, regexes: list[re.Pattern]) -> re.Match | None:
s = text.strip()
for rx in regexes:
m = rx.fullmatch(s)
if m:
return m
return None
def _is_dimension(text: str, regexes: list[re.Pattern]) -> bool:
"""
Anything that's effectively just digits (with optional separators) and is NOT
claimed by any active room pattern — treat as a dimension/measurement value.
"""
if _is_room_marker(text, regexes):
return False
clean = re.sub(r"[\s.,\-x×+]", "", text)
return clean.isdigit() and len(clean) > 0
def _all_text_entities(dxf_path: str) -> list[dict]:
doc = ezdxf.readfile(dxf_path)
msp = doc.modelspace()
out = []
for ent in msp:
try:
if ent.dxftype() == "TEXT":
t = ent.dxf.text.strip()
x, y = ent.dxf.insert.x, ent.dxf.insert.y
elif ent.dxftype() == "MTEXT":
t = _clean_mtext(ent.text)
x, y = ent.dxf.insert.x, ent.dxf.insert.y
else:
continue
if t:
out.append({"text": t, "x": x, "y": y})
except Exception:
pass
return out
def _nearest_description(rx_x: float, ry: float, candidates: list[dict],
regexes: list[re.Pattern], max_dist: float = 8000) -> str | None:
best, best_d = None, max_dist
for c in candidates:
t = c["text"]
if _is_measurement(t) or _is_dimension(t, regexes) or len(t.strip()) < 2:
continue
if _is_room_marker(t, regexes):
continue
d = math.hypot(c["x"] - rx_x, c["y"] - ry)
if d < best_d:
best_d, best = d, t
return best
def extract_rooms(dxf_path: str, examples: list[str] | None = None) -> tuple[list[dict], list[dict]]:
"""
Returns (rooms, unmatched_texts).
examples: user-provided room-number examples; None → DEFAULT_EXAMPLES.
"""
regexes = compile_examples(examples)
entities = _all_text_entities(dxf_path)
room_markers, other = [], []
for e in entities:
m = _is_room_marker(e["text"], regexes)
if m:
room_markers.append({"room": m.group(1), "x": e["x"], "y": e["y"]})
else:
other.append(e)
used: set[str] = set()
seen_rooms: set[str] = set()
rooms: list[dict] = []
for rm in room_markers:
if rm["room"] in seen_rooms:
continue
seen_rooms.add(rm["room"])
desc = _nearest_description(rm["x"], rm["y"], other, regexes)
rooms.append({
"room": rm["room"],
"description": desc or "",
"x": round(rm["x"], 1),
"y": round(rm["y"], 1),
"source": "rule",
"confidence": 1.0 if desc else 0.6,
})
if desc:
used.add(desc)
unmatched = [
e for e in other
if e["text"] not in used
and not _is_measurement(e["text"])
and not _is_dimension(e["text"], regexes)
and len(e["text"]) > 3
]
return rooms, unmatched

Binary file not shown.

80
dwg-rooms/llm_helper.py Normal file
View File

@@ -0,0 +1,80 @@
"""LLM fallback: classify unmatched DXF text entities as rooms via LiteLLM."""
import json
import logging
import os
from openai import AsyncOpenAI
logger = logging.getLogger(__name__)
_client: AsyncOpenAI | None = None
def _get_client() -> AsyncOpenAI:
global _client
if _client is None:
_client = AsyncOpenAI(
base_url=os.getenv("LITELLM_BASE_URL", "http://host.docker.internal:4000"),
api_key=os.getenv("LITELLM_API_KEY", "sk-dummy"),
)
return _client
SYSTEM = """You are a specialist extracting room data from Czech architectural DXF floor plans.
You receive text entities (text, x, y) that were not matched by rule-based parsing.
Identify pairs of room number + Czech room name/description.
Czech room numbers: 4-6 digit codes, sometimes prefixed with "č.m.".
Czech room names: e.g. "Chodba", "Serverovna", "Sklep", "WC", "Kancelář", etc.
Return ONLY a JSON array of objects:
{"room": "XXXXX", "description": "Czech name", "confidence": 0.0-1.0}
Skip: measurements (m2, m²), material names (beton, dlažba), dimensions, unrelated text.
Only include entries with confidence > 0.5."""
async def enhance_with_llm(unmatched: list[dict]) -> list[dict]:
api_key = os.getenv("LITELLM_API_KEY", "")
if not api_key or api_key == "sk-dummy":
logger.info("LITELLM_API_KEY not set — skipping LLM enhancement")
return []
sample = unmatched[:200]
text_block = "\n".join(
f'- "{t["text"]}" x={t["x"]:.0f} y={t["y"]:.0f}' for t in sample
)
model = os.getenv("LLM_MODEL", "gpt-4o-mini")
try:
resp = await _get_client().chat.completions.create(
model=model,
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"Text entities:\n{text_block}"},
],
temperature=0.1,
max_tokens=2000,
)
raw = resp.choices[0].message.content or "[]"
# Strip markdown code fences if present
raw = raw.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
data = json.loads(raw)
if isinstance(data, dict):
data = data.get("rooms", data.get("result", []))
return [
{
"room": str(r["room"]),
"description": r.get("description", ""),
"x": 0.0,
"y": 0.0,
"source": "llm",
"confidence": float(r.get("confidence", 0.7)),
}
for r in data
if isinstance(r, dict) and r.get("room")
]
except Exception as exc:
logger.error("LLM enhancement failed: %s", exc)
return []

143
dwg-rooms/main.py Normal file
View File

@@ -0,0 +1,143 @@
"""FastAPI app: upload DWG/DXF → extract rooms → export Excel."""
import json
import logging
import os
import subprocess
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
from excel_export import export_to_excel
from extractor import DEFAULT_EXAMPLES, extract_rooms
from llm_helper import enhance_with_llm
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="DWG Room Extractor")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/dwg-rooms"))
WORK_DIR.mkdir(parents=True, exist_ok=True)
jobs: dict[str, dict] = {}
def _dwg_to_dxf(dwg_path: Path, out_dir: Path) -> Path:
"""Convert DWG → DXF using LibreDWG dwgread. Returns DXF path."""
dxf_path = out_dir / f"{dwg_path.stem}.dxf"
r = subprocess.run(
["dwgread", "-O", "DXF", "-o", str(dxf_path), str(dwg_path)],
capture_output=True,
text=True,
timeout=120,
)
if not dxf_path.exists():
raise RuntimeError(
f"DWG conversion failed (exit {r.returncode}): {r.stderr or r.stdout or 'no output'}"
)
return dxf_path
@app.get("/")
async def root():
return FileResponse("static/index.html")
@app.get("/api/defaults")
async def defaults():
return {"examples": DEFAULT_EXAMPLES}
@app.post("/api/upload")
async def upload(
file: UploadFile = File(...),
examples: str = Form(default=""), # JSON array of strings
):
suffix = Path(file.filename or "input.dxf").suffix.lower()
if suffix not in (".dwg", ".dxf"):
raise HTTPException(400, "Supported formats: .dwg, .dxf")
try:
ex_list = json.loads(examples) if examples else None
if ex_list is not None and not isinstance(ex_list, list):
ex_list = None
except json.JSONDecodeError:
ex_list = None
job_id = str(uuid.uuid4())
job_dir = WORK_DIR / job_id
job_dir.mkdir()
input_path = job_dir / f"input{suffix}"
content = await file.read()
input_path.write_bytes(content)
logger.info("Job %s: %s (%d bytes), examples=%r", job_id, file.filename, len(content), ex_list)
steps: list[str] = []
try:
if suffix == ".dwg":
steps.append("Konverze DWG → DXF")
dxf_path = _dwg_to_dxf(input_path, job_dir)
else:
dxf_path = input_path
steps.append("Extrakce místností (pravidla)")
rooms, unmatched = extract_rooms(str(dxf_path), examples=ex_list)
logger.info("Job %s: %d rooms, %d unmatched texts", job_id, len(rooms), len(unmatched))
if unmatched and os.getenv("LITELLM_API_KEY", ""):
steps.append("AI rozšíření")
llm_rooms = await enhance_with_llm(unmatched)
logger.info("Job %s: LLM added %d rooms", job_id, len(llm_rooms))
rooms.extend(llm_rooms)
jobs[job_id] = {"filename": file.filename, "rooms": rooms}
return {"job_id": job_id, "room_count": len(rooms), "steps": steps}
except Exception as exc:
logger.error("Job %s failed: %s", job_id, exc)
raise HTTPException(500, str(exc))
@app.get("/api/rooms/{job_id}")
async def get_rooms(job_id: str):
if job_id not in jobs:
raise HTTPException(404, "Not found")
return jobs[job_id]["rooms"]
@app.put("/api/rooms/{job_id}")
async def update_rooms(job_id: str, rooms: list[dict]):
if job_id not in jobs:
raise HTTPException(404, "Not found")
jobs[job_id]["rooms"] = rooms
return {"ok": True, "count": len(rooms)}
@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]
excel_path = WORK_DIR / job_id / "rooms.xlsx"
export_to_excel(job["rooms"], str(excel_path))
stem = Path(job["filename"]).stem
return FileResponse(
str(excel_path),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=f"mistnosti_{stem}.xlsx",
)
@app.get("/health")
async def health():
return {"status": "ok"}
app.mount("/static", StaticFiles(directory="static"), name="static")

View File

@@ -0,0 +1,9 @@
fastapi>=0.115
uvicorn[standard]>=0.30
ezdxf>=1.3
openpyxl>=3.1
pandas>=2.2
python-multipart>=0.0.9
openai>=1.50
python-dotenv>=1.0
aiofiles>=23.0

209
dwg-rooms/static/app.js Normal file
View File

@@ -0,0 +1,209 @@
"use strict";
const $ = id => document.getElementById(id);
const sUpload = $("s-upload");
const sProcessing = $("s-processing");
const sResults = $("s-results");
const dropZone = $("drop-zone");
const fileInput = $("file-input");
const browseBtn = $("browse-btn");
const stepsList = $("steps-list");
const roomsTbody = $("rooms-tbody");
const resultsMeta = $("results-meta");
const exportBtn = $("export-btn");
const resetBtn = $("reset-btn");
const examplesList = $("examples-list");
const examplesForm = $("examples-form");
const exampleInput = $("example-input");
let jobId = null;
let rooms = [];
let examples = [];
// ── Examples management ───────────────────────────
async function loadDefaults() {
try {
const resp = await fetch("/api/defaults");
const data = await resp.json();
examples = Array.isArray(data.examples) ? data.examples : [];
renderExamples();
} catch (e) {
console.warn("Could not load defaults:", e);
}
}
function renderExamples() {
examplesList.innerHTML = "";
examples.forEach((ex, i) => {
const chip = document.createElement("span");
chip.className = "example-chip";
chip.innerHTML = `
${esc(ex)}
<button class="example-chip-remove" type="button" data-i="${i}" aria-label="Odebrat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
</svg>
</button>
`;
examplesList.appendChild(chip);
});
examplesList.querySelectorAll(".example-chip-remove").forEach(btn => {
btn.addEventListener("click", () => {
examples.splice(parseInt(btn.dataset.i, 10), 1);
renderExamples();
});
});
}
examplesForm.addEventListener("submit", e => {
e.preventDefault();
const v = exampleInput.value.trim();
if (!v) return;
if (!/\d/.test(v)) {
exampleInput.focus();
exampleInput.select();
return;
}
if (!examples.includes(v)) examples.push(v);
exampleInput.value = "";
renderExamples();
exampleInput.focus();
});
// ── Drag & drop ───────────────────────────────────
dropZone.addEventListener("dragover", e => { e.preventDefault(); dropZone.classList.add("drag-over"); });
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("drag-over"));
dropZone.addEventListener("drop", e => {
e.preventDefault();
dropZone.classList.remove("drag-over");
const f = e.dataTransfer.files[0];
if (f) handleFile(f);
});
dropZone.addEventListener("click", () => fileInput.click());
browseBtn.addEventListener("click", e => { e.stopPropagation(); fileInput.click(); });
fileInput.addEventListener("change", e => { if (e.target.files[0]) handleFile(e.target.files[0]); });
// ── Upload & process ──────────────────────────────
async function handleFile(file) {
const ext = file.name.split(".").pop().toLowerCase();
if (!["dwg", "dxf"].includes(ext)) {
alert("Podporované formáty jsou .dwg a .dxf");
return;
}
show(sProcessing); hide(sUpload); hide(sResults);
stepsList.innerHTML = "";
addStep("Nahrávání souboru…", "active");
const fd = new FormData();
fd.append("file", file);
fd.append("examples", JSON.stringify(examples));
try {
const resp = await fetch("/api/upload", { method: "POST", body: fd });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
const data = await resp.json();
jobId = data.job_id;
stepsList.innerHTML = "";
for (const step of data.steps) addStep(step, "done");
const rResp = await fetch(`/api/rooms/${jobId}`);
rooms = await rResp.json();
renderResults();
} catch (err) {
stepsList.innerHTML = "";
addStep(`Chyba: ${err.message}`, "error");
setTimeout(() => { show(sUpload); hide(sProcessing); }, 4000);
}
}
function addStep(label, state) {
const el = document.createElement("div");
el.className = `step-item ${state}`;
el.innerHTML = `<span class="step-dot"></span>${esc(label)}`;
stepsList.appendChild(el);
}
// ── Render table ──────────────────────────────────
function renderResults() {
hide(sProcessing); show(sResults);
const total = rooms.length;
const llmCount = rooms.filter(r => r.source === "llm").length;
resultsMeta.textContent = `${total} místností nalezeno${llmCount ? ` (${llmCount} z AI)` : ""}`;
roomsTbody.innerHTML = "";
const sorted = [...rooms].sort((a, b) => String(a.room).localeCompare(String(b.room)));
sorted.forEach((room, i) => {
const tr = document.createElement("tr");
const pct = Math.round((room.confidence ?? 1) * 100);
const badgeCls = room.source === "llm" ? "badge-llm" : "badge-rule";
const badgeTxt = room.source === "llm" ? "AI" : "Pravidla";
tr.innerHTML = `
<td contenteditable="true" data-field="room" data-i="${i}">${esc(room.room)}</td>
<td contenteditable="true" data-field="description" data-i="${i}">${esc(room.description)}</td>
<td><span class="badge ${badgeCls}">${badgeTxt}</span></td>
<td>${pct} %</td>
`;
tr.querySelectorAll("[contenteditable]").forEach(cell => {
cell.addEventListener("blur", () => {
const idx = parseInt(cell.dataset.i, 10);
const field = cell.dataset.field;
rooms[idx][field] = cell.textContent.trim();
});
cell.addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); cell.blur(); }
});
});
roomsTbody.appendChild(tr);
});
}
// ── Export ────────────────────────────────────────
exportBtn.addEventListener("click", async () => {
exportBtn.disabled = true;
exportBtn.textContent = "Ukládám…";
try {
await fetch(`/api/rooms/${jobId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(rooms),
});
window.location.href = `/api/export/${jobId}`;
} finally {
setTimeout(() => {
exportBtn.disabled = false;
exportBtn.innerHTML = `<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v12m0 0-3.75-3.75M12 16.5l3.75-3.75M4.5 19.5h15"/>
</svg>Exportovat Excel`;
}, 1500);
}
});
// ── Reset ─────────────────────────────────────────
resetBtn.addEventListener("click", () => {
jobId = null; rooms = [];
fileInput.value = "";
stepsList.innerHTML = ""; roomsTbody.innerHTML = "";
show(sUpload); hide(sResults); hide(sProcessing);
});
// ── Helpers ───────────────────────────────────────
function show(el) { el.classList.remove("hidden"); }
function hide(el) { el.classList.add("hidden"); }
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// ── Init ──────────────────────────────────────────
loadDefaults();

161
dwg-rooms/static/index.html Normal file
View File

@@ -0,0 +1,161 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Extrakce místností z DWG | Colsys AI</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="/static/styles.css">
<script>
(function () {
var t = null;
try {
var p = new URL(window.location.href).searchParams.get("theme");
if (p === "dark" || p === "light") t = p;
} catch (e) {}
if (!t) { try { t = localStorage.getItem("app_theme"); } catch (e) {} }
if (!t) {
var m = document.cookie.match(/(?:^|;\s*)portal_theme=([^;]+)/);
if (m) t = decodeURIComponent(m[1]);
}
if (t === "dark" || t === "light") {
document.documentElement.classList.add(t);
try { localStorage.setItem("app_theme", t); } catch (e) {}
}
})();
</script>
<style>
.back-link {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 12px 6px 10px; border-radius: 8px;
font-size: 13px; font-weight: 500; color: var(--text-tertiary);
text-decoration: none; border: 0.5px solid var(--border-default);
background: var(--bg-primary); flex-shrink: 0;
transition: color .15s, border-color .15s, background .15s;
}
.back-link:hover {
color: var(--primary); border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
}
.back-link svg { opacity: 0.8; }
@media (max-width: 640px) {
.back-link span { display: none; }
.back-link { padding: 6px; }
}
</style>
</head>
<body>
<header class="header">
<div class="header-inner">
<a href="https://ai.klas.chat" class="brand">
<span class="brand-icon">C</span>
<span class="brand-name">Colsys <span class="brand-ai">AI</span></span>
</a>
<span class="header-crumb">Extrakce místností z DWG / DXF</span>
<a href="https://ai.klas.chat" class="back-link" title="Zpět na portál">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6"/>
</svg>
<span>Zpět na portál</span>
</a>
</div>
</header>
<main class="main">
<!-- ── Upload ────────────────────────────── -->
<section id="s-upload">
<div class="section-intro">
<h1 class="section-title">Extrakce místností z výkresu</h1>
<p class="section-desc">
Nahrajte výkres ve formátu <strong>DWG</strong> nebo <strong>DXF</strong>.
Aplikace automaticky rozpozná čísla a názvy místností a připraví
editovatelnou tabulku pro export do Excelu.
</p>
</div>
<!-- Examples editor -->
<div class="examples-panel">
<div class="examples-header">
<span class="examples-title">Vzorová čísla místností</span>
<span class="examples-subtitle">Zadejte konkrétní příklad čísla z výkresu — cifry se nahradí libovolnými, ostatní znaky zůstanou doslovně.</span>
</div>
<div id="examples-list" class="examples-list"></div>
<form id="examples-form" class="examples-input-row">
<input type="text" id="example-input" class="example-input"
placeholder="např. 4-22408 nebo č.m. 0301" autocomplete="off" inputmode="text">
<button type="submit" class="btn btn-secondary example-add-btn">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
</svg>
Přidat
</button>
</form>
</div>
<div id="drop-zone" class="drop-zone">
<svg class="drop-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 16.5V4.5m0 0-3.75 3.75M12 4.5l3.75 3.75M4.5 19.5h15"/>
</svg>
<p class="drop-text">Přetáhněte soubor DWG nebo DXF sem</p>
<p class="drop-or">nebo</p>
<button class="btn btn-secondary" id="browse-btn" type="button">Vybrat soubor</button>
<p class="drop-formats">Podporované formáty: .dwg &nbsp;·&nbsp; .dxf</p>
<input type="file" id="file-input" accept=".dwg,.dxf" style="display:none">
</div>
</section>
<!-- ── Processing ────────────────────────── -->
<section id="s-processing" class="hidden">
<div class="processing-card">
<div class="spinner"></div>
<h2 class="processing-title">Zpracovávám výkres…</h2>
<div id="steps-list" class="steps-list"></div>
</div>
</section>
<!-- ── Results ───────────────────────────── -->
<section id="s-results" class="hidden">
<div class="results-header">
<div>
<h2 class="results-title">Nalezené místnosti</h2>
<p class="results-meta" id="results-meta"></p>
</div>
<div class="results-actions">
<button class="btn btn-secondary" id="reset-btn" type="button">Nahrát jiný soubor</button>
<button class="btn btn-primary" id="export-btn" type="button">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v12m0 0-3.75-3.75M12 16.5l3.75-3.75M4.5 19.5h15"/>
</svg>
Exportovat Excel
</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:130px">Č. místnosti</th>
<th>Popis / účel</th>
<th style="width:110px">Zdroj</th>
<th style="width:100px">Spolehlivost</th>
</tr>
</thead>
<tbody id="rooms-tbody"></tbody>
</table>
</div>
<p class="table-hint">Kliknutím na buňku ji upravíte — změny se projeví v exportu.</p>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>

459
dwg-rooms/static/styles.css Normal file
View File

@@ -0,0 +1,459 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #ffffff;
--bg-secondary: #f9fafb;
--bg-tertiary: #f2f4f7;
--text-primary: #101828;
--text-secondary: #354052;
--text-tertiary: #676f83;
--text-quaternary: #98a2b2;
--border-default: rgb(16 24 40 / 0.08);
--border-strong: #d0d5dc;
--border-subtle: rgb(16 24 40 / 0.04);
--card: #ffffff;
--primary: #155aef;
--primary-hover: #004aeb;
--accent-indigo: #444ce7;
--shadow-card: 0 1px 2px rgb(16 24 40 / 0.05);
--radius-md: 8px;
--radius-lg: 12px;
}
@media (prefers-color-scheme: dark) {
:root:not(.light) {
--bg-primary: #14181f;
--bg-secondary: #1a1f29;
--bg-tertiary: #232936;
--text-primary: #f5f7fa;
--text-secondary: #c8ccd5;
--text-tertiary: #98a2b2;
--text-quaternary: #676f83;
--border-default: rgb(255 255 255 / 0.08);
--border-strong: #354052;
--border-subtle: rgb(255 255 255 / 0.04);
--card: #1a1f29;
}
}
:root.dark {
--bg-primary: #14181f;
--bg-secondary: #1a1f29;
--bg-tertiary: #232936;
--text-primary: #f5f7fa;
--text-secondary: #c8ccd5;
--text-tertiary: #98a2b2;
--text-quaternary: #676f83;
--border-default: rgb(255 255 255 / 0.08);
--border-strong: #354052;
--border-subtle: rgb(255 255 255 / 0.04);
--card: #1a1f29;
}
body {
font-family: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--bg-secondary);
color: var(--text-primary);
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
}
/* ── Header ─────────────────────────────────────── */
.header {
position: sticky;
top: 0;
z-index: 30;
border-bottom: 0.5px solid var(--border-default);
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
backdrop-filter: blur(8px);
}
.header-inner {
max-width: 1280px;
margin: 0 auto;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
gap: 16px;
}
@media (min-width: 640px) {
.header-inner { padding: 0 32px; }
}
.brand {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
flex-shrink: 0;
}
.brand-icon {
width: 28px;
height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent-indigo) 100%);
box-shadow: 0 1px 2px rgb(16 24 40 / 0.06), inset 0 1px 0 rgb(255 255 255 / 0.18);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12px;
letter-spacing: -0.04em;
color: white;
}
.brand-name {
font-size: 14px;
font-weight: 600;
letter-spacing: -0.025em;
color: var(--text-primary);
}
.brand-ai { color: var(--primary); }
.header-crumb {
font-size: 13px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Main ────────────────────────────────────────── */
.main {
max-width: 900px;
margin: 0 auto;
padding: 40px 24px 80px;
display: flex;
flex-direction: column;
gap: 32px;
}
@media (max-width: 640px) {
.header-inner { padding: 0 16px; }
.main { padding: 24px 16px 60px; }
}
/* ── Section intro ───────────────────────────────── */
.section-intro { margin-bottom: 24px; }
.section-title {
font-size: 22px;
font-weight: 600;
letter-spacing: -0.02em;
margin-bottom: 8px;
}
.section-desc {
color: var(--text-tertiary);
max-width: 580px;
font-size: 14px;
line-height: 1.6;
}
/* ── Examples panel ──────────────────────────────── */
.examples-panel {
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
background: var(--card);
box-shadow: var(--shadow-card);
padding: 16px;
margin-bottom: 16px;
}
.examples-header {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 12px;
}
.examples-title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.examples-subtitle {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.5;
}
.examples-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 28px;
margin-bottom: 10px;
}
.examples-list:empty::before {
content: "Zatím žádné vzory — přidejte alespoň jeden níže";
font-size: 12px;
color: var(--text-quaternary);
font-style: italic;
padding: 6px 0;
}
.example-chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
color: var(--text-primary);
padding: 4px 4px 4px 10px;
border-radius: 999px;
font-size: 12px;
font-family: ui-monospace, monospace;
}
.example-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-tertiary);
padding: 0;
transition: background 0.1s, color 0.1s;
}
.example-chip-remove:hover {
background: color-mix(in srgb, var(--primary) 18%, transparent);
color: var(--text-primary);
}
.example-chip-remove svg {
width: 12px;
height: 12px;
}
.examples-input-row {
display: flex;
gap: 8px;
}
.example-input {
flex: 1;
font-family: ui-monospace, monospace;
font-size: 13px;
padding: 8px 12px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--bg-primary);
color: var(--text-primary);
min-width: 0;
}
.example-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
.example-add-btn {
flex-shrink: 0;
}
/* ── Drop zone ───────────────────────────────────── */
.drop-zone {
border: 1.5px dashed var(--border-strong);
border-radius: var(--radius-lg);
background: var(--card);
padding: 56px 32px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
box-shadow: var(--shadow-card);
}
.drop-zone:hover, .drop-zone.drag-over {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, var(--card));
}
.drop-icon {
width: 44px;
height: 44px;
color: var(--text-quaternary);
margin: 0 auto 18px;
display: block;
transition: color 0.15s;
}
.drop-zone:hover .drop-icon, .drop-zone.drag-over .drop-icon {
color: var(--primary);
}
.drop-text {
font-size: 15px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
}
.drop-or {
font-size: 13px;
color: var(--text-quaternary);
margin-bottom: 14px;
}
.drop-formats {
font-size: 12px;
color: var(--text-quaternary);
margin-top: 14px;
}
/* ── Buttons ─────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
transition: background 0.15s, box-shadow 0.15s;
line-height: 1;
}
.btn-primary {
background: var(--primary);
color: #fff;
box-shadow: 0 1px 2px rgb(21 90 239 / 0.25);
}
.btn-primary:hover { background: var(--primary-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: default; }
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-strong);
}
.btn-secondary:hover { background: var(--border-strong); }
.btn-icon { width: 15px; height: 15px; flex-shrink: 0; }
/* ── Processing card ─────────────────────────────── */
.processing-card {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 56px 32px;
text-align: center;
box-shadow: var(--shadow-card);
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid var(--border-strong);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.75s linear infinite;
margin: 0 auto 22px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.processing-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 20px;
color: var(--text-primary);
}
.steps-list {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 320px;
margin: 0 auto;
text-align: left;
}
.step-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--text-tertiary);
padding: 8px 12px;
border-radius: var(--radius-md);
background: var(--bg-tertiary);
}
.step-item.active {
color: var(--primary);
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
}
.step-item.done { color: #17b26a; }
.step-item.error { color: #d92d20; background: #fef3f2; }
.step-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
/* ── Results ─────────────────────────────────────── */
.results-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.results-title {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.015em;
margin-bottom: 4px;
}
.results-meta {
font-size: 13px;
color: var(--text-tertiary);
}
.results-actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* ── Table ───────────────────────────────────────── */
.table-wrap {
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-card);
background: var(--card);
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
min-width: 500px;
}
thead {
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-default);
}
th {
padding: 10px 14px;
text-align: left;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary);
white-space: nowrap;
}
td {
padding: 8px 14px;
border-bottom: 1px solid var(--border-subtle);
color: var(--text-primary);
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr:nth-child(even) td { background: var(--bg-secondary); }
td[contenteditable]:focus {
outline: none;
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
box-shadow: inset 0 0 0 1.5px var(--primary);
border-radius: 3px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 99px;
font-size: 11px;
font-weight: 500;
}
.badge-rule { background: #d1e0ff; color: #004aeb; }
.badge-llm { background: #d1fae5; color: #065f46; }
@media (prefers-color-scheme: dark) {
.badge-rule { background: #1e3a8a; color: #93c5fd; }
.badge-llm { background: #064e3b; color: #6ee7b7; }
}
.table-hint {
font-size: 12px;
color: var(--text-quaternary);
margin-top: 8px;
}
.hidden { display: none !important; }