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

View File

@@ -0,0 +1,5 @@
# LiteLLM proxy endpoint and key for this app.
# Generate a virtual key via the LiteLLM admin UI (alias: contract-check).
LITELLM_BASE_URL=http://host.docker.internal:4000/v1
LITELLM_API_KEY=sk-...
LLM_MODEL=anthropic/claude-sonnet-4-20250514

23
contract-check/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM python:3.12-slim
# Tesseract + Czech language pack for OCR on scanned PDFs; DejaVu for Czech-glyph
# rendering in the generated summary PDF.
RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr \
tesseract-ocr-ces \
tesseract-ocr-eng \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /tmp/contract-check
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

150
contract-check/analyzer.py Normal file
View File

@@ -0,0 +1,150 @@
"""LLM-based contract analysis against a user-supplied checklist."""
import io
import json
import logging
import os
from pathlib import Path
import fitz # PyMuPDF
from PIL import Image
import pytesseract
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/v1"),
api_key=os.getenv("LITELLM_API_KEY", "sk-dummy"),
)
return _client
MODEL = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-20250514")
SYSTEM_PROMPT = """Jste expert na české obchodní právo a pomáháte podnikateli posoudit smlouvu, kterou má podepsat.
Vaším úkolem je analyzovat smlouvu z pohledu PŘÍJEMCE smlouvy (toho, kdo ji má podepsat), nikoliv toho, kdo ji sepsal. Hledáte rizika, nevýhodná ustanovení a věci, na které by si měl dát příjemce pozor.
Text smlouvy obdržíte rozdělený podle stran — každá strana začíná značkou "--- Strana N ---". U každého citátu MUSÍTE uvést, na které straně se nachází.
Pro každý bod kontrolního seznamu posoudíte smlouvu a vrátíte JSON s následující strukturou:
{
"items": [
{
"id": "id_polozky",
"status": "ok" | "warning" | "problem" | "missing",
"title": "Krátký nadpis nálezu (3-7 slov)",
"summary": "1-3 věty vysvětlující nález z pohledu příjemce smlouvy",
"excerpts": [
{
"text": "Přesný citát ze smlouvy (krátký, 5-15 slov, ideálně dohledatelný v PDF)",
"comment": "Stručný komentář k úryvku — co znamená / proč je to relevantní",
"page": <číslo strany, kde se citát nachází (celé číslo)>
}
]
}
],
"overall_summary": "Celkové shrnutí 3-5 vět: hlavní rizika a hlavní výhody. ŽÁDNÁ doporučení co dělat.",
"risk_level": "low" | "medium" | "high"
}
Pravidla:
- Status "ok" = ustanovení je vyvážené a bezpečné pro příjemce
- Status "warning" = stojí za pozornost, ne nutně problém
- Status "problem" = jasně nevýhodné nebo riskantní pro příjemce
- Status "missing" = bod se ve smlouvě nepokrývá (chybí ustanovení)
- Excerpts musí být DOSLOVNÉ citáty ze smlouvy (kvůli zvýraznění v PDF). Pokud nelze citovat (např. status=missing), vraťte prázdné pole [].
- KAŽDÝ citát MUSÍ obsahovat číslo strany ("page"). Stranu poznáte podle značky "--- Strana N ---" před textem.
- NEDÁVEJTE doporučení co s nálezem dělat — uživatel sám ví, co bude řešit. Pouze konstatujte fakta.
- Komentáře piště česky, věcně a stručně.
- Nevymýšlejte si — pokud něco ve smlouvě není, řekněte to.
- Vraťte POUZE JSON, žádný markdown, žádný text okolo."""
async def analyze_contract(pdf_path: Path, checklist_items: list[dict]) -> dict:
"""Run LLM analysis on a contract PDF against the given checklist."""
text, used_ocr = _extract_text(pdf_path)
if not text.strip():
raise RuntimeError(
"Z PDF se nepodařilo získat žádný text — ani standardním "
"způsobem ani OCR. Zkuste lepší kvalitu skenu nebo PDF s textovou vrstvou."
)
checklist_block = "\n".join(
f"- id={it['id']}: {it['label']}{it.get('hint', '')}"
for it in checklist_items
)
user_msg = f"""KONTROLNÍ SEZNAM (co posoudit):
{checklist_block}
TEXT SMLOUVY:
{text}
Vraťte JSON s analýzou každé položky kontrolního seznamu."""
resp = await _get_client().chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
temperature=0.1,
max_tokens=8000,
)
raw = (resp.choices[0].message.content or "").strip()
logger.info("Analysis raw length: %d chars", len(raw))
# Strip markdown fences if model wrapped it anyway
raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
logger.error("JSON parse failed: %s\n%s", e, raw[:1000])
raise RuntimeError(f"Nepodařilo se zpracovat odpověď AI: {e}")
# Tell the caller whether we had to OCR — UI can show a notice and
# we'll skip in-page highlighting since the original PDF has no text layer.
data["_used_ocr"] = used_ocr
return data
def _extract_text(pdf_path: Path) -> tuple[str, bool]:
"""Extract text from PDF. Falls back to OCR if the PDF has no text layer.
Returns (text, used_ocr).
"""
doc = fitz.open(str(pdf_path))
parts = []
total_chars = 0
for i, page in enumerate(doc):
t = page.get_text()
parts.append(f"--- Strana {i + 1} ---\n{t}")
total_chars += len(t.strip())
# Heuristic: <20 chars per page on average → looks like a scan
needs_ocr = doc.page_count > 0 and (total_chars / max(doc.page_count, 1)) < 20
if not needs_ocr:
doc.close()
return "\n\n".join(parts), False
logger.info("PDF appears to be scanned (~%d chars across %d pages) — running OCR",
total_chars, doc.page_count)
parts = []
for i, page in enumerate(doc):
# Rasterize page at 250 DPI for OCR quality
pix = page.get_pixmap(dpi=250, alpha=False)
img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
try:
ocr_text = pytesseract.image_to_string(img, lang="ces+eng")
except pytesseract.TesseractError as e:
logger.error("Tesseract failed on page %d: %s", i + 1, e)
ocr_text = ""
parts.append(f"--- Strana {i + 1} ---\n{ocr_text}")
doc.close()
return "\n\n".join(parts), True

View File

@@ -0,0 +1,98 @@
"""Default checklist of items to review in a Czech business contract.
Each item targets a risk that disadvantages the RECIPIENT of the contract
(the party being asked to sign), not the drafter.
"""
DEFAULT_CHECKLIST = [
{
"id": "sankce",
"label": "Smluvní pokuty a sankce",
"hint": "Jsou pokuty vyvážené? Stejné pro obě strany? Není výše nepřiměřená?",
"default": True,
},
{
"id": "odpovednost",
"label": "Omezení odpovědnosti za škodu",
"hint": "Je odpovědnost druhé strany omezena nepřiměřeně nízkou částkou? Vyloučení následných škod?",
"default": True,
},
{
"id": "vypovedni_lhuta",
"label": "Výpovědní lhůta a podmínky výpovědi",
"hint": "Je délka přiměřená? Jsou důvody pro výpověď spravedlivé? Jednostranné podmínky?",
"default": True,
},
{
"id": "auto_prodlouzeni",
"label": "Automatické prodloužení smlouvy",
"hint": "Hrozí, že se smlouva sama prodlouží? Lhůta pro odmítnutí prodloužení?",
"default": True,
},
{
"id": "cena_eskalace",
"label": "Cena, indexace a její navyšování",
"hint": "Může druhá strana cenu jednostranně zvyšovat? Vazba na inflaci/jiný index?",
"default": True,
},
{
"id": "platebni_podminky",
"label": "Platební podmínky a úroky z prodlení",
"hint": "Splatnost faktur, výše úroků z prodlení, předfaktury, zálohy.",
"default": True,
},
{
"id": "mlcenlivost",
"label": "Mlčenlivost (NDA)",
"hint": "Jednostranná? Doba trvání? Co spadá pod důvěrné informace?",
"default": True,
},
{
"id": "gdpr",
"label": "Ochrana osobních údajů (GDPR)",
"hint": "Zpracování osobních údajů, role správce/zpracovatele, dodatek o zpracování (DPA).",
"default": True,
},
{
"id": "ip_prava",
"label": "Duševní vlastnictví a licence",
"hint": "Komu patří výsledky? Rozsah licence? Možnost převodu?",
"default": True,
},
{
"id": "rozhodne_pravo",
"label": "Rozhodné právo a místo soudu",
"hint": "České právo? Příslušnost soudu? Rozhodčí doložka?",
"default": True,
},
{
"id": "force_majeure",
"label": "Vyšší moc (force majeure)",
"hint": "Jaké události spadají? Jsou nepřiměřeně omezené?",
"default": False,
},
{
"id": "postoupeni",
"label": "Postoupení smlouvy třetí straně",
"hint": "Může druhá strana smlouvu postoupit bez vašeho souhlasu?",
"default": False,
},
{
"id": "exkluzivita",
"label": "Exkluzivita a zákaz konkurence",
"hint": "Zavazujete se nepracovat s konkurencí? Po dobu trvání i po skončení?",
"default": False,
},
{
"id": "zaruka_kvalita",
"label": "Záruka a reklamace",
"hint": "Délka záruky, postup reklamace, vyloučení odpovědnosti za vady.",
"default": False,
},
{
"id": "zmeny_smlouvy",
"label": "Změny smlouvy a dodatky",
"hint": "Pouze písemně? Souhlas obou stran? Jednostranné úpravy?",
"default": False,
},
]

View File

@@ -0,0 +1,24 @@
services:
contract-check:
build: .
container_name: contract-check
restart: unless-stopped
ports:
- "127.0.0.1:3027:8000"
environment:
LITELLM_BASE_URL: ${LITELLM_BASE_URL:-http://host.docker.internal:4000/v1}
LITELLM_API_KEY: ${LITELLM_API_KEY:-}
LLM_MODEL: ${LLM_MODEL:-anthropic/claude-sonnet-4-20250514}
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- localai
volumes:
- contract-check-data:/tmp/contract-check
volumes:
contract-check-data:
networks:
localai:
external: true

134
contract-check/main.py Normal file
View File

@@ -0,0 +1,134 @@
"""FastAPI app: upload contract PDF → analyze against checklist → annotated PDF."""
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 analyzer import analyze_contract
from checklist import DEFAULT_CHECKLIST
from pdf_annotator import annotate
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="Contract Terms Check")
app.add_middleware(CORSMiddleware, allow_origins=["*"],
allow_methods=["*"], allow_headers=["*"])
WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/contract-check"))
WORK_DIR.mkdir(parents=True, exist_ok=True)
jobs: dict[str, dict] = {}
@app.get("/")
async def root():
return FileResponse("static/index.html")
@app.get("/api/checklist")
async def get_checklist():
"""Default checklist items the UI can pre-populate."""
return {"items": DEFAULT_CHECKLIST}
@app.post("/api/upload")
async def upload(file: UploadFile = File(...)):
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 / "input.pdf"
raw = await file.read()
input_path.write_bytes(raw)
logger.info("Job %s: %s (%d bytes)", job_id, file.filename, len(raw))
jobs[job_id] = {
"filename": file.filename,
"job_dir": str(job_dir),
"input_path": str(input_path),
"analysis": None,
"checklist": None,
}
return {"job_id": job_id}
class AnalyzeRequest(BaseModel):
items: list[dict] # [{id, label, hint?, default?}]
@app.post("/api/analyze/{job_id}")
async def analyze(job_id: str, req: AnalyzeRequest):
if job_id not in jobs:
raise HTTPException(404, "Úloha nenalezena")
if not req.items:
raise HTTPException(400, "Vyberte alespoň jednu položku ke kontrole")
job = jobs[job_id]
input_path = Path(job["input_path"])
try:
analysis = await analyze_contract(input_path, req.items)
except Exception as exc:
logger.exception("Analysis failed")
raise HTTPException(500, str(exc))
# Merge LLM-returned items with the original checklist labels so the UI
# can show the user-facing label even if the LLM was terse.
labels_by_id = {it["id"]: it["label"] for it in req.items}
for it in analysis.get("items", []):
if "label" not in it and it.get("id") in labels_by_id:
it["label"] = labels_by_id[it["id"]]
job["analysis"] = analysis
job["checklist"] = req.items
job["used_ocr"] = bool(analysis.get("_used_ocr"))
return analysis
@app.get("/api/annotated/{job_id}")
async def annotated_pdf(job_id: str):
if job_id not in jobs:
raise HTTPException(404, "Úloha nenalezena")
job = jobs[job_id]
if not job.get("analysis"):
raise HTTPException(400, "Nejprve spusťte analýzu")
input_path = Path(job["input_path"])
out_path = Path(job["job_dir"]) / "annotated.pdf"
analysis = job["analysis"]
# For OCR-only contracts the original PDF has no text layer; skip the
# excerpt-search step so we don't waste time on guaranteed misses.
skip_highlights = bool(job.get("used_ocr"))
try:
await asyncio.to_thread(
annotate, input_path, out_path,
analysis.get("items", []),
analysis.get("overall_summary", ""),
analysis.get("risk_level", ""),
skip_highlights,
job.get("filename") or "",
)
except Exception as exc:
logger.exception("Annotation failed")
raise HTTPException(500, f"Anotace selhala: {exc}")
stem = Path(job["filename"]).stem if job.get("filename") else "smlouva"
return FileResponse(
str(out_path),
media_type="application/pdf",
filename=f"kontrola_{stem}.pdf",
)
@app.get("/health")
async def health():
return {"status": "ok"}
app.mount("/static", StaticFiles(directory="static"), name="static")

View File

@@ -0,0 +1,257 @@
"""Add color-coded highlights and a Czech-correct summary page to a PDF."""
import logging
from pathlib import Path
import fitz # PyMuPDF
logger = logging.getLogger(__name__)
# RGB 0-1 for PyMuPDF
COLORS = {
"ok": (0.69, 0.91, 0.69), # green
"warning": (1.00, 0.90, 0.45), # yellow
"problem": (1.00, 0.65, 0.65), # red
"missing": (0.85, 0.85, 0.85), # grey
}
# DejaVu Sans is shipped via fonts-dejavu-core; supports full Czech glyph set.
FONT_PATH_SANS = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
FONT_PATH_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
def annotate(input_pdf: Path, output_pdf: Path, items: list[dict],
overall_summary: str = "", risk_level: str = "",
skip_highlights: bool = False,
contract_name: str = "") -> Path:
"""Open input_pdf, highlight excerpts, prepend a summary page."""
doc = fitz.open(str(input_pdf))
highlighted_count = 0
not_found_count = 0
items_to_annotate = [] if skip_highlights else items
if skip_highlights:
logger.info("Skipping per-excerpt highlights (OCR'd PDF — no text layer)")
for item in items_to_annotate:
color = COLORS.get(item.get("status", "warning"), COLORS["warning"])
title = item.get("title") or item.get("label") or item.get("id", "")
for ex in item.get("excerpts") or []:
quote = (ex.get("text") or "").strip()
comment = (ex.get("comment") or "").strip()
if not quote:
continue
found_any = False
for page in doc:
rects = page.search_for(quote, quads=False)
if not rects and len(quote) > 20:
rects = page.search_for(quote[:20], quads=False)
if not rects:
continue
for rect in rects:
annot = page.add_highlight_annot(rect)
annot.set_colors(stroke=color)
annot.set_info(
title=title,
content=f"{title}\n\n{comment}" if comment else title,
)
annot.update()
found_any = True
if found_any:
highlighted_count += 1
else:
not_found_count += 1
logger.info("Quote not found in PDF: %r", quote[:60])
# Build & prepend summary
summary = _build_summary_pdf(doc, items, overall_summary, risk_level,
contract_name)
if summary:
doc.insert_pdf(summary, start_at=0)
summary.close()
# Set PDF metadata title for nice display in viewers
if contract_name:
meta = doc.metadata or {}
meta["title"] = f"Kontrola: {contract_name}"
doc.set_metadata(meta)
doc.save(str(output_pdf), garbage=4, deflate=True)
doc.close()
logger.info("Annotated PDF: highlighted=%d not_found=%d", highlighted_count, not_found_count)
return output_pdf
def _build_summary_pdf(orig_doc, items: list[dict],
overall_summary: str, risk_level: str,
contract_name: str):
"""Build a 1-N page summary PDF using a Czech-supporting font."""
if not orig_doc.page_count:
return None
src_rect = orig_doc[0].rect
width = max(float(src_rect.width), 595.0) # ensure at least A4
height = max(float(src_rect.height), 842.0)
new = fitz.open()
page = new.new_page(width=width, height=height)
_register_fonts(page)
margin_x = 50.0
y = 50.0
title_size = 18
body_size = 10.5
line_h = body_size * 1.45
# Header line: filename
if contract_name:
_draw_text(page, contract_name, margin_x, y, font="sans",
size=11, color=(0.40, 0.45, 0.55))
y += 18
# Title
_draw_text(page, "Kontrola smluvních podmínek", margin_x, y,
font="bold", size=title_size, color=(0.06, 0.10, 0.20))
y += title_size * 1.6
# Risk badge line
if risk_level:
labels = {"low": "NÍZKÉ", "medium": "STŘEDNÍ", "high": "VYSOKÉ"}
colors = {
"low": (0.20, 0.65, 0.32),
"medium": (0.85, 0.55, 0.10),
"high": (0.80, 0.20, 0.20),
}
_draw_text(page, f"Celková míra rizika: {labels.get(risk_level, risk_level.upper())}",
margin_x, y, font="bold", size=12,
color=colors.get(risk_level, (0.4, 0.4, 0.4)))
y += 22
# Overall summary
if overall_summary:
y = _wrap_text(page, overall_summary, margin_x, y,
width - 2 * margin_x, body_size, font="sans")
y += 12
y += 6
page.draw_line((margin_x, y), (width - margin_x, y),
color=(0.85, 0.85, 0.85))
y += 16
_draw_text(page, "Položky kontroly", margin_x, y,
font="bold", size=12, color=(0.15, 0.20, 0.30))
y += 18
status_labels = {"ok": "OK", "warning": "POZOR",
"problem": "PROBLÉM", "missing": "CHYBÍ"}
for item in items:
# Need new page?
if y > height - 80:
page = new.new_page(width=width, height=height)
_register_fonts(page)
y = 50
status = item.get("status", "")
color = COLORS.get(status, (0.6, 0.6, 0.6))
label = status_labels.get(status, status.upper())
title = item.get("title") or item.get("label") or item.get("id", "")
# Colored bullet square
page.draw_rect(
fitz.Rect(margin_x, y, margin_x + 10, y + 10),
color=color, fill=color,
)
_draw_text(page, f"[{label}] {title}",
margin_x + 18, y, font="bold", size=11,
color=(0.06, 0.10, 0.20))
y += line_h + 4
summary = item.get("summary", "")
if summary:
y = _wrap_text(page, summary, margin_x + 18, y,
width - 2 * margin_x - 18, body_size,
font="sans", color=(0.30, 0.35, 0.45))
# List page references for each excerpt
excerpts = item.get("excerpts") or []
if excerpts:
for ex in excerpts:
pg = ex.get("page")
text = (ex.get("text") or "").strip()
if not text:
continue
pg_str = f"str. {pg}: " if pg else ""
snippet = text if len(text) <= 90 else text[:87] + ""
y = _wrap_text(page, f"{pg_str}{snippet}\"",
margin_x + 18, y,
width - 2 * margin_x - 18, body_size - 0.5,
font="sans", color=(0.25, 0.30, 0.40))
cmt = (ex.get("comment") or "").strip()
if cmt:
y = _wrap_text(page, f"{cmt}",
margin_x + 18, y,
width - 2 * margin_x - 18, body_size - 0.5,
font="sans", color=(0.45, 0.50, 0.60))
y += 12
return new
# ── font helpers ─────────────────────────────────────────
def _register_fonts(page):
"""Insert DejaVu Sans (regular + bold) on the page if available."""
try:
page.insert_font(fontname="sans", fontfile=FONT_PATH_SANS)
except Exception as e:
logger.warning("Could not register DejaVuSans: %s", e)
try:
page.insert_font(fontname="bold", fontfile=FONT_PATH_BOLD)
except Exception:
# Fall back to regular for bold
try:
page.insert_font(fontname="bold", fontfile=FONT_PATH_SANS)
except Exception:
pass
def _draw_text(page, text: str, x: float, y: float,
font: str = "sans", size: float = 10.5,
color: tuple = (0.06, 0.10, 0.20)):
"""Render a single line at baseline y+size."""
try:
page.insert_text((x, y + size), text,
fontname=font, fontsize=size, color=color)
except Exception:
# Fallback to PyMuPDF built-in (may mangle diacritics but won't crash)
page.insert_text((x, y + size), text,
fontsize=size, color=color)
def _wrap_text(page, text: str, x: float, y: float, max_width: float,
font_size: float, font: str = "sans",
color: tuple = (0.06, 0.10, 0.20)) -> float:
"""Word-wrap `text` and return the new y position."""
line_h = font_size * 1.45
# PyMuPDF has Page.get_text_length() for width calculation
def measure(s: str) -> float:
try:
return fitz.get_text_length(s, fontname=font, fontsize=font_size)
except Exception:
return len(s) * font_size * 0.50
words = text.split()
if not words:
return y
line = ""
for word in words:
candidate = (line + " " + word).strip()
if measure(candidate) > max_width and line:
_draw_text(page, line, x, y, font=font,
size=font_size, color=color)
y += line_h
line = word
else:
line = candidate
if line:
_draw_text(page, line, x, y, font=font, size=font_size, color=color)
y += line_h
return y

View File

@@ -0,0 +1,9 @@
fastapi>=0.115
uvicorn[standard]>=0.30
PyMuPDF>=1.24
pytesseract>=0.3.10
Pillow>=10.0
python-multipart>=0.0.9
openai>=1.50
python-dotenv>=1.0
aiofiles>=23.0

View File

@@ -0,0 +1,264 @@
// Contract check frontend
(() => {
const $ = (id) => document.getElementById(id);
const sections = {
setup: $("s-setup"),
processing: $("s-processing"),
results: $("s-results"),
};
const show = (name) => {
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name);
};
let state = {
jobId: null,
checklist: [],
analysis: null,
pendingFile: null, // File chosen by user but not yet uploaded
};
let nextCustomId = 1;
// ── Load default checklist ──
async function loadChecklist() {
try {
const r = await fetch("/api/checklist");
const data = await r.json();
state.checklist = (data.items || []).map((it) => ({
...it,
checked: it.default !== false,
}));
renderChecklist();
} catch (err) {
alert("Nepodařilo se načíst kontrolní seznam: " + err.message);
}
}
function renderChecklist() {
const c = $("checklist");
c.innerHTML = "";
for (const item of state.checklist) {
const div = document.createElement("label");
div.className = "checklist-item" + (item.custom ? " custom" : "");
div.innerHTML = `
<input type="checkbox" ${item.checked ? "checked" : ""}>
<div class="checklist-item-body">
<div class="checklist-item-label">${escapeHtml(item.label)}</div>
${item.hint ? `<div class="checklist-item-hint">${escapeHtml(item.hint)}</div>` : ""}
</div>
${item.custom ? `<button class="checklist-remove" type="button" title="Odstranit">×</button>` : ""}
`;
const cb = div.querySelector("input");
cb.addEventListener("change", () => {
item.checked = cb.checked;
updateRunButton();
});
const rm = div.querySelector(".checklist-remove");
if (rm) {
rm.addEventListener("click", (e) => {
e.preventDefault();
state.checklist = state.checklist.filter((x) => x.id !== item.id);
renderChecklist();
updateRunButton();
});
}
c.appendChild(div);
}
updateRunButton();
}
$("check-all-btn").addEventListener("click", () => {
for (const it of state.checklist) it.checked = true;
renderChecklist();
});
$("uncheck-all-btn").addEventListener("click", () => {
for (const it of state.checklist) it.checked = false;
renderChecklist();
});
$("add-item-form").addEventListener("submit", (e) => {
e.preventDefault();
const input = $("add-item-input");
const label = input.value.trim();
if (!label) return;
state.checklist.push({
id: `custom_${nextCustomId++}`,
label,
hint: "",
checked: true,
custom: true,
});
input.value = "";
renderChecklist();
});
// ── File selection (just stores the file, doesn't upload yet) ──
const fileInput = $("file-input");
const dropZone = $("drop-zone");
$("browse-btn").addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", (e) => e.target.files[0] && setFile(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] && setFile(e.dataTransfer.files[0]));
function setFile(file) {
state.pendingFile = file;
$("file-info-name").textContent = file.name;
$("file-info").classList.remove("hidden");
dropZone.classList.add("compact");
updateRunButton();
}
$("file-info-clear").addEventListener("click", () => {
state.pendingFile = null;
fileInput.value = "";
$("file-info").classList.add("hidden");
dropZone.classList.remove("compact");
updateRunButton();
});
function updateRunButton() {
const hasFile = !!state.pendingFile;
const selectedCount = state.checklist.filter((it) => it.checked).length;
const btn = $("run-btn");
const hint = $("run-hint");
// run-hint only exists after a file is selected (lives in the file-info strip)
if (!hasFile) {
btn.disabled = true;
return;
}
if (selectedCount === 0) {
btn.disabled = true;
if (hint) hint.textContent = "Vyberte alespoň jednu položku ke kontrole níže.";
} else {
btn.disabled = false;
if (hint) hint.textContent = `Připraveno spustit kontrolu ${selectedCount} bodů.`;
}
}
$("run-btn").addEventListener("click", async () => {
if (!state.pendingFile) return;
const selected = state.checklist.filter((it) => it.checked);
if (!selected.length) return;
show("processing");
$("processing-title").textContent = "Nahrávám smlouvu…";
try {
const fd = new FormData();
fd.append("file", state.pendingFile);
const ur = await fetch("/api/upload", { method: "POST", body: fd });
if (!ur.ok) throw new Error((await ur.json()).detail || ur.statusText);
const upJson = await ur.json();
state.jobId = upJson.job_id;
$("processing-title").textContent = `Analyzuji smlouvu (${selected.length} bodů)…`;
const ar = await fetch(`/api/analyze/${state.jobId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: selected.map((it) => ({
id: it.id,
label: it.label,
hint: it.hint || "",
})),
}),
});
if (!ar.ok) throw new Error((await ar.json()).detail || ar.statusText);
state.analysis = await ar.json();
renderResults();
show("results");
} catch (err) {
alert("Chyba: " + err.message);
show("setup");
}
});
function renderResults() {
const a = state.analysis;
const items = a.items || [];
const usedOcr = !!a._used_ocr;
const counts = items.reduce((acc, it) => {
acc[it.status || "warning"] = (acc[it.status || "warning"] || 0) + 1;
return acc;
}, {});
$("results-meta").textContent =
`Vyhodnoceno ${items.length} bodů. ` +
`OK: ${counts.ok || 0}, Pozor: ${counts.warning || 0}, ` +
`Problém: ${counts.problem || 0}, Chybí: ${counts.missing || 0}.`;
const riskMap = {
low: ["Nízké riziko", "risk-low"],
medium: ["Střední riziko", "risk-medium"],
high: ["Vysoké riziko", "risk-high"],
};
const r = riskMap[a.risk_level] || ["—", ""];
const overall = $("overall-card");
const ocrNotice = usedOcr
? `<div class="ocr-notice">⚙ Smlouva neměla textovou vrstvu — použito OCR (rozpoznávání textu z obrazu). Stažené PDF bude obsahovat jen souhrnnou stránku, bez zvýraznění v původním textu (kvalita OCR neumožňuje spolehlivé vyhledání citací).</div>`
: "";
overall.innerHTML = `
<div class="risk-badge ${r[1]}">${r[0]}</div>
<div class="overall-text">
${escapeHtml(a.overall_summary || "")}
${ocrNotice}
</div>
`;
const findings = $("findings");
findings.innerHTML = "";
const labelMap = { ok: "OK", warning: "POZOR", problem: "PROBLÉM", missing: "CHYBÍ" };
// Sort: problem > warning > missing > ok
const order = { problem: 0, warning: 1, missing: 2, ok: 3 };
const sorted = [...items].sort((a, b) =>
(order[a.status] ?? 9) - (order[b.status] ?? 9)
);
for (const it of sorted) {
const div = document.createElement("div");
div.className = `finding status-${it.status || "warning"}`;
const excerptsHtml = (it.excerpts || []).map((ex) => `
<div class="excerpt">
<div class="excerpt-text">
${ex.page ? `<span class="excerpt-page">str. ${ex.page}</span>` : ""}
<span>„${escapeHtml(ex.text || "")}"</span>
</div>
${ex.comment ? `<div class="excerpt-comment">${escapeHtml(ex.comment)}</div>` : ""}
</div>
`).join("");
div.innerHTML = `
<div class="finding-header">
<span class="finding-status">${labelMap[it.status] || it.status || ""}</span>
<span class="finding-title">${escapeHtml(it.title || it.label || it.id)}</span>
</div>
${it.summary ? `<p class="finding-summary">${escapeHtml(it.summary)}</p>` : ""}
${excerptsHtml ? `<div class="finding-excerpts">${excerptsHtml}</div>` : ""}
`;
findings.appendChild(div);
}
}
$("download-pdf-btn").addEventListener("click", () => {
if (!state.jobId) return;
window.location.href = `/api/annotated/${state.jobId}`;
});
$("restart-btn").addEventListener("click", () => {
state = {
jobId: null,
checklist: state.checklist,
analysis: null,
pendingFile: null,
};
fileInput.value = "";
$("file-info").classList.add("hidden");
dropZone.classList.remove("compact");
show("setup");
updateRunButton();
});
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
loadChecklist();
})();

View File

@@ -0,0 +1,341 @@
/* Contract-check specific styles */
/* Compact drop zone after a file is selected (still allows replacing) */
.drop-zone.compact {
padding: 16px 24px;
}
.drop-zone.compact .drop-icon,
.drop-zone.compact .drop-text,
.drop-zone.compact .drop-or,
.drop-zone.compact .drop-formats {
display: none;
}
.file-info {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
margin: 12px 0 24px;
background: var(--card);
border: 1px solid var(--border-default);
border-left: 3px solid var(--primary);
border-radius: 8px;
font-size: 14px;
color: var(--text-primary);
position: sticky;
top: 64px; /* sits just below the sticky header */
z-index: 10;
box-shadow: var(--shadow-card);
}
.file-info svg { color: var(--primary); flex-shrink: 0; }
.file-info-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.file-info-name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-info-hint {
font-size: 12px;
color: var(--text-tertiary);
}
.file-info .btn-link { flex-shrink: 0; }
.file-info .btn { flex-shrink: 0; }
@media (max-width: 640px) {
.file-info { flex-wrap: wrap; }
.file-info-text { flex-basis: 100%; }
}
.btn-link {
background: transparent;
border: none;
color: var(--primary);
font-size: 13px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
flex-shrink: 0;
}
.btn-link:hover { background: color-mix(in srgb, var(--primary) 8%, transparent); }
.run-row {
display: flex;
align-items: center;
gap: 14px;
margin-top: 22px;
padding-top: 22px;
border-top: 1px solid var(--border-default);
flex-wrap: wrap;
}
.btn-lg {
padding: 12px 24px;
font-size: 14px;
font-weight: 600;
}
.run-hint {
font-size: 13px;
color: var(--text-tertiary);
}
.checklist-panel {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 18px;
margin-bottom: 24px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
gap: 12px;
}
.panel-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.panel-actions { display: flex; gap: 6px; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.checklist {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 16px;
margin-bottom: 14px;
}
@media (max-width: 720px) {
.checklist { grid-template-columns: 1fr; }
}
.checklist-item {
display: flex;
gap: 8px;
align-items: flex-start;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
transition: background 0.12s;
}
.checklist-item:hover { background: var(--bg-tertiary); }
.checklist-item input[type=checkbox] {
width: 16px;
height: 16px;
margin-top: 2px;
accent-color: var(--primary);
cursor: pointer;
flex-shrink: 0;
}
.checklist-item-body { flex: 1; min-width: 0; }
.checklist-item-label {
font-size: 13.5px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.35;
}
.checklist-item-hint {
font-size: 11.5px;
color: var(--text-tertiary);
margin-top: 2px;
line-height: 1.4;
}
.checklist-item.custom .checklist-item-label::after {
content: " vlastní";
font-size: 10px;
color: var(--primary);
background: color-mix(in srgb, var(--primary) 12%, transparent);
padding: 1px 6px;
border-radius: 999px;
margin-left: 6px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.checklist-remove {
background: transparent;
border: none;
color: var(--text-quaternary);
cursor: pointer;
font-size: 14px;
padding: 0 4px;
align-self: center;
}
.checklist-remove:hover { color: #dc2626; }
.add-item-row {
display: flex;
gap: 8px;
border-top: 1px solid var(--border-default);
padding-top: 14px;
}
.add-item-input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--border-default);
background: var(--bg-primary);
color: var(--text-primary);
border-radius: 8px;
font-size: 13px;
font-family: inherit;
}
.add-item-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
.processing-sub {
font-size: 13px;
color: var(--text-tertiary);
margin: 8px auto 0;
max-width: 480px;
}
/* Results */
.overall-card {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 20px 22px;
margin: 16px 0 22px;
display: flex;
gap: 18px;
align-items: flex-start;
}
.risk-badge {
flex-shrink: 0;
padding: 8px 14px;
border-radius: 8px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.risk-low { background: rgba(34, 197, 94, 0.12); color: #15803d; }
.risk-medium { background: rgba(245, 158, 11, 0.15); color: #b45309; }
.risk-high { background: rgba(239, 68, 68, 0.12); color: #b91c1c; }
.overall-text {
font-size: 14px;
line-height: 1.55;
color: var(--text-secondary);
}
.ocr-notice {
margin-top: 12px;
padding: 10px 14px;
background: rgba(245, 158, 11, 0.08);
border-left: 3px solid #f59e0b;
border-radius: 6px;
font-size: 12.5px;
color: var(--text-secondary);
}
.findings {
display: flex;
flex-direction: column;
gap: 10px;
}
.finding {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 14px 18px;
border-left: 4px solid var(--border-default);
}
.finding.status-ok { border-left-color: #22c55e; }
.finding.status-warning { border-left-color: #f59e0b; }
.finding.status-problem { border-left-color: #ef4444; }
.finding.status-missing { border-left-color: #94a3b8; }
.finding-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.finding-status {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
padding: 3px 8px;
border-radius: 999px;
text-transform: uppercase;
}
.status-ok .finding-status { background: rgba(34,197,94,0.14); color: #15803d; }
.status-warning .finding-status { background: rgba(245,158,11,0.16); color: #b45309; }
.status-problem .finding-status { background: rgba(239,68,68,0.14); color: #b91c1c; }
.status-missing .finding-status { background: rgba(148,163,184,0.20); color: #475569; }
.finding-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.finding-summary {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.55;
margin: 4px 0 0;
}
.finding-excerpts {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.excerpt {
font-size: 12.5px;
background: var(--bg-tertiary);
padding: 8px 12px;
border-radius: 6px;
border-left: 3px solid var(--border-strong);
}
.excerpt-text {
color: var(--text-primary);
font-style: italic;
}
.excerpt-comment {
margin-top: 4px;
font-size: 12px;
color: var(--text-tertiary);
}
.excerpt-page {
display: inline-block;
margin-right: 6px;
padding: 1px 7px;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.02em;
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
border-radius: 999px;
font-style: normal;
vertical-align: 1px;
}
.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; }
}

View File

@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kontrola smluvních podmínek | 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 smluvních podmínek</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">
<!-- ── Setup ──────────────────────────────── -->
<section id="s-setup">
<div class="section-intro">
<h1 class="section-title">Kontrola smluvních podmínek</h1>
<p class="section-desc">
Nahrajte PDF smlouvu, vyberte body ke kontrole a spusťte analýzu.
Výsledkem je shrnutí s odkazy na strany a PDF se zvýrazněnými pasážemi.
</p>
</div>
<!-- 1. Upload first -->
<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 PDF smlouvu 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át: .pdf</p>
<input type="file" id="file-input" accept=".pdf" style="display:none">
</div>
<!-- Selected-file row with inline action (shown after pick) -->
<div id="file-info" class="file-info hidden">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<div class="file-info-text">
<span class="file-info-name" id="file-info-name"></span>
<span class="file-info-hint" id="run-hint">Vyberte body ke kontrole níže (výchozí jsou předvybrané).</span>
</div>
<button class="btn-link" id="file-info-clear" type="button">Změnit</button>
<button class="btn btn-primary" id="run-btn" type="button" disabled>
Spustit analýzu
</button>
</div>
<!-- Checklist -->
<div class="checklist-panel">
<div class="panel-header">
<h2 class="panel-title">Co kontrolovat</h2>
<div class="panel-actions">
<button class="btn btn-secondary btn-sm" id="check-all-btn" type="button">Vybrat vše</button>
<button class="btn btn-secondary btn-sm" id="uncheck-all-btn" type="button">Zrušit vše</button>
</div>
</div>
<div id="checklist" class="checklist"></div>
<form id="add-item-form" class="add-item-row">
<input type="text" id="add-item-input" class="add-item-input"
placeholder="Vlastní bod ke kontrole (např. „Zákaz vývozu IP" nebo Penále za pozdní dodání")"
autocomplete="off" maxlength="200">
<button type="submit" class="btn btn-secondary">
+ Přidat
</button>
</form>
</div>
</section>
<!-- ── Processing ────────────────────────── -->
<section id="s-processing" class="hidden">
<div class="processing-card">
<div class="spinner"></div>
<h2 class="processing-title" id="processing-title">Analyzuji smlouvu…</h2>
<p class="processing-sub">Toto může trvat 3090 sekund. AI prochází text smlouvy
a porovnává ji s vybranými body.</p>
</div>
</section>
<!-- ── Results ───────────────────────────── -->
<section id="s-results" 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-pdf-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 PDF se zvýrazněním
</button>
</div>
</div>
<div class="overall-card" id="overall-card"></div>
<div class="findings" id="findings"></div>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>

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; }