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

8
vv-check/Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /tmp/vv-check
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,18 @@
services:
vv-check:
build: .
container_name: vv-check
restart: unless-stopped
ports:
- "127.0.0.1:3033:8000"
networks:
- localai
volumes:
- vv-check-data:/tmp/vv-check
volumes:
vv-check-data:
networks:
localai:
external: true

79
vv-check/main.py Normal file
View File

@@ -0,0 +1,79 @@
"""FastAPI: Excel workbook → detect VV sheets → find price inconsistencies → Excel report."""
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 vv_logic import analyse, write_report
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="VV Price Check")
app.add_middleware(CORSMiddleware, allow_origins=["*"],
allow_methods=["*"], allow_headers=["*"])
WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/vv-check"))
WORK_DIR.mkdir(parents=True, exist_ok=True)
jobs: dict[str, dict] = {}
@app.get("/")
async def root():
return FileResponse("static/index.html")
@app.post("/api/check")
async def check(file: UploadFile = File(...)):
suffix = Path(file.filename or "").suffix.lower()
if suffix not in (".xlsx", ".xlsm"):
raise HTTPException(400, "Podporované formáty: .xlsx, .xlsm")
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)
try:
result = analyse(input_path)
except Exception as exc:
logger.exception("Analysis failed")
raise HTTPException(500, f"Analýza selhala: {exc}")
jobs[job_id] = {
"filename": file.filename,
"job_dir": str(job_dir),
"result": result,
}
return {"job_id": job_id, "result": result}
@app.get("/api/report/{job_id}")
async def report(job_id: str):
if job_id not in jobs:
raise HTTPException(404, "Úloha nenalezena")
job = jobs[job_id]
out_path = Path(job["job_dir"]) / "report.xlsx"
write_report(job["result"], job.get("filename") or "kalkulace.xlsx", out_path)
stem = Path(job["filename"]).stem if job.get("filename") else "kalkulace"
return FileResponse(
str(out_path),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=f"kontrola_cen_{stem}.xlsx",
)
@app.get("/health")
async def health():
return {"status": "ok"}
app.mount("/static", StaticFiles(directory="static"), name="static")

View File

@@ -0,0 +1,4 @@
fastapi>=0.115
uvicorn[standard]>=0.30
openpyxl>=3.1
python-multipart>=0.0.9

179
vv-check/static/app.js Normal file
View File

@@ -0,0 +1,179 @@
// VV price-check frontend
(() => {
const $ = (id) => document.getElementById(id);
const sections = {
upload: $("s-upload"),
processing: $("s-processing"),
result: $("s-result"),
};
const show = (n) => {
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== n);
};
let state = { jobId: null, result: null };
// Upload
const fileInput = $("file-input");
const dropZone = $("drop-zone");
$("browse-btn").addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", (e) => e.target.files[0] && upload(e.target.files[0]));
["dragenter", "dragover"].forEach((ev) =>
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); }));
["dragleave", "drop"].forEach((ev) =>
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove("drag-over"); }));
dropZone.addEventListener("drop", (e) => e.dataTransfer.files[0] && upload(e.dataTransfer.files[0]));
async function upload(file) {
show("processing");
try {
const fd = new FormData();
fd.append("file", file);
const r = await fetch("/api/check", { method: "POST", body: fd });
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
const json = await r.json();
state.jobId = json.job_id;
state.result = json.result;
renderResult();
show("result");
} catch (err) {
alert("Chyba: " + err.message);
show("upload");
}
}
function fmtPrice(v) {
if (v == null || isNaN(v)) return "—";
return v.toLocaleString("cs-CZ", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " Kč";
}
function renderResult() {
const r = state.result;
const vvCount = r.vv_sheet_count;
const pricedCount = r.vv_sheets_with_prices || 0;
const incCount = r.total_inconsistencies;
const totalSheets = r.sheets.length;
$("results-meta").textContent =
`${totalSheets} listů v sešitu, ${vvCount} identifikováno jako VV, ${pricedCount} s vyplněnými cenami.`;
const sheetsHtml = r.sheets.map((s) => {
let cls = "not-vv";
let label = s.name;
let icon = "·";
if (s.is_vv && s.priced_items > 0) {
cls = "vv";
icon = "✓";
label = `${s.name} (${s.priced_items} s cenou)`;
} else if (s.is_vv && s.items > 0) {
cls = "vv-noprice";
icon = "⚠";
label = `${s.name} (${s.items} bez cen)`;
} else if (s.is_vv) {
cls = "not-vv";
label = `${s.name} (prázdné)`;
}
return `<span class="sheet-pill ${cls}" title="${escapeHtml(s.name)}">${icon} ${escapeHtml(label)}</span>`;
}).join("");
$("sheets-summary").innerHTML = `
<div class="summary-stat">
<div class="summary-stat-value">${vvCount}</div>
<div class="summary-stat-label">listů VV</div>
</div>
<div class="summary-stat">
<div class="summary-stat-value">${pricedCount}</div>
<div class="summary-stat-label">s cenami</div>
</div>
<div class="summary-stat">
<div class="summary-stat-value ${incCount === 0 ? "ok" : "problem"}">${incCount}</div>
<div class="summary-stat-label">nesouladů</div>
</div>
<div class="detected-sheets">${sheetsHtml}</div>
`;
const wrap = $("incs-wrap");
// Special case: no VVs at all
if (vvCount === 0) {
wrap.innerHTML = `
<div class="empty-state">
<p><strong>V sešitu nebyl rozpoznán žádný výkaz výměr.</strong></p>
<p style="margin-top:8px;color:var(--text-tertiary);font-size:13px">
Aplikace hledá listy s hlavičkou „Popis / MJ / Výměra / Jedn. cena".
Pokud váš sešit používá jiný formát, dejte vědět přes
<a href="https://navrhy.klas.chat">Návrh nového nástroje</a>.
</p>
</div>`;
return;
}
// VVs found but none have prices — point user at correct tool
if (pricedCount === 0) {
wrap.innerHTML = `
<div class="empty-state">
<p><strong>Nalezeno ${vvCount} listů VV, ale žádný nemá vyplněné jednotkové ceny.</strong></p>
<p style="margin-top:8px;color:var(--text-tertiary);font-size:13px">
„Kontrola cen ve VV" porovnává jednotkové ceny napříč listy — bez vyplněných cen není co porovnávat.<br>
Pokud chcete porovnat tento sešit s předchozí verzí (změny ve výměrách, přidané/odebrané položky),
použijte <a href="https://porovnani-vv.klas.chat">Porovnání VV</a>.
</p>
</div>`;
return;
}
// VVs with prices found but no inconsistencies
if (incCount === 0) {
wrap.innerHTML = `
<div class="empty-state">
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="m9 12 2 2 4-4"/>
</svg>
<p>Všechny položky se stejným názvem mají shodnou jednotkovou cenu ve všech VV listech.</p>
</div>`;
return;
}
wrap.innerHTML = r.inconsistencies.map((inc) => {
const minP = Math.min(...inc.rows.map(r => r.unit_price));
const maxP = Math.max(...inc.rows.map(r => r.unit_price));
const rows = inc.rows.map((rw) => {
const cls = rw.unit_price === maxP && minP !== maxP ? "row-max"
: rw.unit_price === minP && minP !== maxP ? "row-min" : "";
return `<tr class="${cls}">
<td>${escapeHtml(rw.sheet)}</td>
<td>${rw.row}</td>
<td>${escapeHtml(rw.mj)}</td>
<td class="num">${fmtPrice(rw.unit_price)}</td>
</tr>`;
}).join("");
const spread = maxP > 0 ? ((maxP - minP) / minP * 100) : 0;
return `
<div class="inc-card">
<div class="inc-card-title">${escapeHtml(inc.description)}</div>
<div class="inc-card-meta">
Vyskytuje se ${inc.occurrences}× ve ${new Set(inc.rows.map(r => r.sheet)).size} listech ·
rozdíl ${fmtPrice(minP)} ${fmtPrice(maxP)} (rozptyl ${spread.toFixed(1)} %)
</div>
<table class="inc-table">
<thead><tr><th>List VV</th><th>Řádek</th><th>MJ</th><th>Jed. cena</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}).join("");
}
$("download-btn").addEventListener("click", () => {
if (!state.jobId) return;
window.location.href = `/api/report/${state.jobId}`;
});
$("restart-btn").addEventListener("click", () => {
state = { jobId: null, result: null };
fileInput.value = "";
show("upload");
});
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
})();

133
vv-check/static/extra.css Normal file
View File

@@ -0,0 +1,133 @@
/* VV check specific styles */
.processing-sub {
font-size: 13px;
color: var(--text-tertiary);
margin: 8px auto 0;
max-width: 400px;
}
.sheets-summary {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 16px 18px;
margin: 16px 0 18px;
display: flex;
gap: 18px;
flex-wrap: wrap;
font-size: 13px;
color: var(--text-secondary);
}
.summary-stat { display: flex; flex-direction: column; gap: 2px; min-width: 100px; }
.summary-stat-value {
font-size: 22px;
font-weight: 600;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.summary-stat-value.problem { color: #b91c1c; }
.summary-stat-value.ok { color: #16a34a; }
.summary-stat-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary);
}
.detected-sheets {
margin-left: auto;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-self: center;
}
.sheet-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
border-radius: 999px;
font-size: 11.5px;
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.sheet-pill.vv { background: rgba(34, 197, 94, 0.14); color: #15803d; font-weight: 500; }
.sheet-pill.vv-noprice { background: rgba(245, 158, 11, 0.15); color: #b45309; font-weight: 500; }
.sheet-pill.not-vv { color: var(--text-quaternary); }
.empty-state a { color: var(--primary); }
.incs-wrap {
display: flex;
flex-direction: column;
gap: 10px;
}
.inc-card {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 14px 18px;
border-left: 4px solid #f59e0b;
}
.inc-card-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
}
.inc-card-meta {
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 8px;
}
.inc-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.inc-table th, .inc-table td {
padding: 6px 10px;
border-bottom: 1px solid var(--border-default);
text-align: left;
}
.inc-table th {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
background: var(--bg-secondary);
}
.inc-table td.num { text-align: right; font-variant-numeric: tabular-nums; }
.inc-table tr.row-max td { color: #b91c1c; font-weight: 500; }
.inc-table tr.row-min td { color: #15803d; }
.empty-state {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 40px 24px;
text-align: center;
color: var(--text-secondary);
}
.empty-state .empty-icon {
width: 48px;
height: 48px;
color: #16a34a;
margin: 0 auto 14px;
}
/* Back link */
.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; }
}

104
vv-check/static/index.html Normal file
View File

@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kontrola cen ve VV | 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">
<link rel="stylesheet" href="/static/extra.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>
</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">Kontrola cen ve VV</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">
<path d="m15 18-6-6 6-6"/>
</svg>
<span>Zpět na portál</span>
</a>
</div>
</header>
<main class="main">
<section id="s-upload">
<div class="section-intro">
<h1 class="section-title">Kontrola jednotkových cen ve výkazech výměr</h1>
<p class="section-desc">
Nahrajte Excel s interní kalkulací (více listů typu VV). Aplikace
najde položky se stejným názvem ve více VV listech a upozorní
na rozdílné jednotkové ceny.
</p>
</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 sešit XLSX 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: .xlsx, .xlsm</p>
<input type="file" id="file-input" accept=".xlsx,.xlsm" style="display:none">
</div>
</section>
<section id="s-processing" class="hidden">
<div class="processing-card">
<div class="spinner"></div>
<h2 class="processing-title">Analyzuji listy VV…</h2>
<p class="processing-sub">Procházím všechny listy, načítám položky a hledám shodné názvy s odlišnými cenami. Při větších sešitech to může trvat půl minuty i déle — vyčkejte, nezavírejte stránku.</p>
</div>
</section>
<section id="s-result" class="hidden">
<div class="results-header">
<div>
<h2 class="results-title">Výsledek kontroly</h2>
<p class="results-meta" id="results-meta"></p>
</div>
<div class="results-actions">
<button class="btn btn-secondary" id="restart-btn" type="button">Nová kontrola</button>
<button class="btn btn-primary" id="download-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>
Stáhnout Excel report
</button>
</div>
</div>
<div class="sheets-summary" id="sheets-summary"></div>
<div class="incs-wrap" id="incs-wrap"></div>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>

461
vv-check/static/styles.css Normal file
View File

@@ -0,0 +1,461 @@
*, *::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;
}
/* Dark theme — applies when (a) user OS prefers dark and no .light override,
or (b) :root has explicit .dark class (set by portal_theme cookie). */
@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;
background: var(--bg-secondary);
color: var(--text-primary);
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── 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; /* match portal max-w-7xl so brand doesn't shift */
margin: 0 auto;
height: 56px; /* portal uses h-14 */
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px; /* portal px-4 */
gap: 16px;
}
@media (min-width: 640px) {
.header-inner { padding: 0 32px; } /* portal sm:px-8 */
}
.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; /* tailwind text-sm */
font-weight: 600;
letter-spacing: -0.025em; /* tailwind tracking-tight */
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; }

312
vv-check/vv_logic.py Normal file
View File

@@ -0,0 +1,312 @@
"""Detect VV sheets in an Excel workbook and find items with inconsistent
unit prices across them.
A "VV" sheet is identified by either:
- Its name contains "VV" (case-insensitive), OR
- It has a typical VV header row with columns matching Poř./Kód/Popis/MJ/Výměra/cena.
Items are matched by their description text (normalised: trimmed, multiple
spaces collapsed). The unit-price comparison is exact (rounded to 2 decimals
to absorb floating-point noise).
"""
import logging
import re
from collections import defaultdict
from pathlib import Path
from typing import Iterable
import openpyxl
from openpyxl.utils import get_column_letter
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.workbook import Workbook
logger = logging.getLogger(__name__)
# Heuristic header keywords (Czech). We look for the row with at least three of these.
HEADER_HINTS = {
"popis": ["popis", "název", "název položky", "naziv"],
"mj": ["mj", "j.j.", "jednotka", "měrná jednotka"],
"vymera": ["výměra", "vymera", "množství", "mnozstvi", "počet", "pocet"],
# Unit-price column. Common Czech spellings include "Jedn. cena",
# "J. cena", "Jednotková cena", "cena/jed", "kč/mj", ...
"cena_jed": ["jednotková cena", "jednotkova cena",
"jedn. cena", "jedn.cena", "jedn cena",
"j. cena", "j.cena", "j cena",
"jed. cena", "jed.cena", "jed cena",
"cena/jed", "cena za jednotku", "cena j.",
"cena jed", "cena za mj", "kč/mj"],
"cena_tot": ["cena celkem", "cena", "celkem"],
}
# Allowed VV-name patterns (case-insensitive substring match).
VV_NAME_PATTERNS = ["vv", "výkaz", "vykaz"]
def normalise(text) -> str:
if text is None:
return ""
return re.sub(r"\s+", " ", str(text).strip()).lower()
def is_vv_sheet(ws) -> tuple[bool, dict | None]:
"""Return (is_vv, header_columns) where header_columns maps role → col index (1-based)."""
name_match = any(p in ws.title.lower() for p in VV_NAME_PATTERNS)
# Scan first 12 rows for a header row
header_row = None
header_cols: dict[str, int] = {}
for row_idx in range(1, min(13, ws.max_row + 1)):
row_values = [(c, normalise(ws.cell(row=row_idx, column=c).value))
for c in range(1, min(15, ws.max_column + 1))]
matched_roles = {}
for col_idx, val in row_values:
for role, hints in HEADER_HINTS.items():
if role in matched_roles:
continue
if any(val == h or val.startswith(h) for h in hints):
matched_roles[role] = col_idx
break
if len(matched_roles) >= 3 and "popis" in matched_roles \
and ("cena_jed" in matched_roles or "cena_tot" in matched_roles):
header_row = row_idx
header_cols = matched_roles
header_cols["_header_row"] = row_idx
break
return ((name_match or header_row is not None), header_cols if header_row else None)
def extract_items(ws, header_cols: dict) -> list[dict]:
"""Yield item dicts from a VV sheet given its header columns.
Returns items even when unit_price is missing (None) so the UI can
report "this sheet is a VV but has no prices" instead of silently
dropping everything.
"""
header_row = header_cols.get("_header_row", 1)
popis_col = header_cols.get("popis")
mj_col = header_cols.get("mj")
vymera_col = header_cols.get("vymera")
cena_jed_col = header_cols.get("cena_jed")
if not popis_col:
return []
items: list[dict] = []
for r in range(header_row + 1, ws.max_row + 1):
popis = ws.cell(row=r, column=popis_col).value
if popis is None or not str(popis).strip():
continue
popis_text = str(popis).strip()
# Skip section rows like "001: Rozvaděče" — empty MJ + colon in popis
mj_val = ws.cell(row=r, column=mj_col).value if mj_col else None
if not mj_val and ":" in popis_text and len(popis_text) < 60:
continue
up: float | None = None
if cena_jed_col:
raw_price = ws.cell(row=r, column=cena_jed_col).value
up = _to_float(raw_price)
if up is not None:
up = round(up, 2)
items.append({
"row": r,
"description": popis_text,
"description_norm": normalise(popis_text),
"mj": str(mj_val).strip() if mj_val else "",
"quantity": _to_float(ws.cell(row=r, column=vymera_col).value) if vymera_col else None,
"unit_price": up,
})
return items
def _to_float(v):
if v is None or v == "":
return None
try:
return float(v)
except (ValueError, TypeError):
return None
def analyse(xlsx_path: Path) -> dict:
"""Run the full price-check analysis. Returns a structured report."""
wb = openpyxl.load_workbook(xlsx_path, data_only=True)
sheets_info = []
vv_items: dict[str, list[dict]] = {}
for ws in wb.worksheets:
is_vv, header_cols = is_vv_sheet(ws)
info = {
"name": ws.title,
"is_vv": bool(is_vv),
"items": 0, # total rows recognised as items
"priced_items": 0, # items with a unit price filled in
"has_unit_price_col": False,
}
if is_vv and header_cols:
info["has_unit_price_col"] = bool(header_cols.get("cena_jed"))
items = extract_items(ws, header_cols)
info["items"] = len(items)
info["priced_items"] = sum(1 for it in items if it["unit_price"] is not None)
vv_items[ws.title] = items
sheets_info.append(info)
# Only items WITH a unit price participate in the price-consistency check
grouped: dict[str, list[tuple[str, dict]]] = defaultdict(list)
for sheet_name, items in vv_items.items():
for it in items:
if it["unit_price"] is None:
continue
grouped[it["description_norm"]].append((sheet_name, it))
# Inconsistencies: same description appearing in 2+ sheets with different price
inconsistencies = []
for desc_norm, entries in grouped.items():
if len(entries) < 2:
continue
sheets_present = {s for s, _ in entries}
if len(sheets_present) < 2:
continue # appears multiple times in same sheet — not a cross-sheet issue
prices = {round(it["unit_price"], 2) for _, it in entries}
if len(prices) < 2:
continue
# Use the longest seen description as canonical (more readable)
canonical = max((it["description"] for _, it in entries), key=len)
rows = []
for sheet_name, it in entries:
rows.append({
"sheet": sheet_name,
"row": it["row"],
"mj": it["mj"],
"unit_price": it["unit_price"],
})
inconsistencies.append({
"description": canonical,
"occurrences": len(entries),
"distinct_prices": sorted(prices),
"rows": rows,
})
# Sort by description for stable output
inconsistencies.sort(key=lambda x: x["description"].lower())
vv_sheets_with_prices = sum(
1 for s in sheets_info if s["is_vv"] and s["priced_items"] > 0
)
return {
"sheets": sheets_info,
"vv_sheet_count": sum(1 for s in sheets_info if s["is_vv"]),
"vv_sheets_with_prices": vv_sheets_with_prices,
"total_inconsistencies": len(inconsistencies),
"inconsistencies": inconsistencies,
}
# ── Excel report writer ─────────────────────────────────────────────
BLUE = "1F4E78"
WHITE = "FFFFFF"
GRAY = "F2F2F2"
RED_BG = "FCE4E4"
THIN = Side(style="thin", color="BFBFBF")
BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
def write_report(result: dict, source_filename: str, out_path: Path) -> Path:
wb = Workbook()
ws = wb.active
ws.title = "Nesoulady"
# Title row
ws.cell(row=1, column=1, value="Kontrola jednotkových cen ve výkazech výměr").font = \
Font(name="Arial", bold=True, size=14, color=BLUE)
ws.merge_cells("A1:F1")
ws.cell(row=2, column=1, value=f"Zdroj: {source_filename}").font = \
Font(name="Arial", italic=True, size=10, color="595959")
ws.merge_cells("A2:F2")
# Header
headers = ["Název položky", "List VV", "Řádek", "MJ", "Jednotková cena", "Poznámka"]
for c, h in enumerate(headers, 1):
cell = ws.cell(row=4, column=c, value=h)
cell.font = Font(name="Arial", bold=True, size=11, color=WHITE)
cell.fill = PatternFill("solid", fgColor=BLUE)
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.border = BORDER
row = 5
if not result["inconsistencies"]:
ws.cell(row=row, column=1,
value="Žádné nesoulady — všechny položky se stejným názvem mají shodné jednotkové ceny.").font = \
Font(name="Arial", size=11, color="006100", italic=True)
ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=6)
else:
for inc in result["inconsistencies"]:
min_price = min(r["unit_price"] for r in inc["rows"])
max_price = max(r["unit_price"] for r in inc["rows"])
for r_info in inc["rows"]:
note_parts = []
if r_info["unit_price"] == min_price:
note_parts.append("nejnižší")
if r_info["unit_price"] == max_price:
note_parts.append("nejvyšší")
note = ", ".join(note_parts)
values = [
inc["description"],
r_info["sheet"],
r_info["row"],
r_info["mj"],
r_info["unit_price"],
note,
]
for c, v in enumerate(values, 1):
cell = ws.cell(row=row, column=c, value=v)
cell.font = Font(name="Arial", size=10)
cell.border = BORDER
cell.alignment = Alignment(vertical="top",
wrap_text=(c == 1))
if c == 5:
cell.number_format = '#,##0.00 "";[Red]-#,##0.00 "";"-"'
cell.alignment = Alignment(horizontal="right", vertical="top")
# Highlight rows with the highest price as a visual cue
if r_info["unit_price"] == max_price and min_price != max_price:
for c in range(1, 7):
ws.cell(row=row, column=c).fill = PatternFill("solid", fgColor=RED_BG)
row += 1
row += 0
# Column widths
widths = {1: 56, 2: 22, 3: 8, 4: 8, 5: 16, 6: 16}
for c, w in widths.items():
ws.column_dimensions[get_column_letter(c)].width = w
ws.freeze_panes = "A5"
ws.auto_filter.ref = f"A4:F{max(5, row - 1)}"
# ── Second sheet: per-sheet breakdown ─────────────────────
s2 = wb.create_sheet("Detekované listy")
s2.cell(row=1, column=1, value="Přehled listů v sešitu").font = \
Font(name="Arial", bold=True, size=14, color=BLUE)
s2.merge_cells("A1:D1")
s2_headers = ["Název listu", "Je VV?", "Počet položek s cenou", "Poznámka"]
for c, h in enumerate(s2_headers, 1):
cell = s2.cell(row=3, column=c, value=h)
cell.font = Font(name="Arial", bold=True, size=11, color=WHITE)
cell.fill = PatternFill("solid", fgColor=BLUE)
cell.border = BORDER
r = 4
for s in result["sheets"]:
priced = s.get("priced_items", 0)
s2.cell(row=r, column=1, value=s["name"]).border = BORDER
s2.cell(row=r, column=2, value=("Ano" if s["is_vv"] else "Ne")).border = BORDER
s2.cell(row=r, column=3, value=priced).border = BORDER
note = ""
if s["is_vv"] and priced == 0:
note = "list VV bez jednotkových cen — nelze kontrolovat"
elif not s["is_vv"]:
note = "neidentifikován jako VV"
s2.cell(row=r, column=4, value=note).border = BORDER
for c in range(1, 5):
s2.cell(row=r, column=c).font = Font(name="Arial", size=10)
r += 1
for c, w in {1: 30, 2: 10, 3: 22, 4: 40}.items():
s2.column_dimensions[get_column_letter(c)].width = w
wb.save(str(out_path))
return out_path