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

26
.env.example Normal file
View File

@@ -0,0 +1,26 @@
# --- Required ---
# Secret used by Auth.js to sign session JWTs. Generate with:
# openssl rand -base64 32
AUTH_SECRET=change-me-please
# Public URL of the portal. In dev this is the Caddy-served URL.
NEXTAUTH_URL=https://ai.klas.chat
# --- Dev access (Credentials provider) ---
# Single-password gate used while Microsoft Entra is not yet provisioned.
# Leave empty in production to disable the password fallback.
DEV_PORTAL_PASSWORD=
# --- Microsoft Entra ID (production) ---
# Fill these once an admin provisions an app registration in Entra.
# Leaving them empty hides the "Continue with Microsoft" button.
AUTH_MICROSOFT_ENTRA_ID_ID=
AUTH_MICROSOFT_ENTRA_ID_SECRET=
# Tenant-specific issuer, e.g. https://login.microsoftonline.com/<tenant-id>/v2.0
AUTH_MICROSOFT_ENTRA_ID_ISSUER=
# --- Langfuse (optional, only when you uncomment the langfuse services) ---
# LANGFUSE_DB_PASSWORD=
# LANGFUSE_NEXTAUTH_SECRET=
# LANGFUSE_SALT=

53
.gitignore vendored Normal file
View File

@@ -0,0 +1,53 @@
# ── Secrets ──────────────────────────────────────────────────
# Never commit env files — every subapp keeps its own LiteLLM virtual key
# in .env. Use .env.example to document the required variables.
.env
.env.*
!.env.example
# ── Build artifacts ──────────────────────────────────────────
node_modules/
.next/
.turbo/
.vercel/
.swc/
__pycache__/
*.pyc
*.pyo
*.egg-info/
build/
dist/
# ── Runtime / volumes ────────────────────────────────────────
# Bind-mounted Docker volumes and tmp work dirs — never commit user data.
feature-request/data/
*/tmp/
# ── Test / sample artifacts (kept locally, not in repo) ──────
# Add specific samples back to the repo deliberately if needed.
ideas/*.xlsx
ideas/*.pdf
dwg-counting/test*.pdf
dwg-counting/symbol.png
dwg-counting/*.dwg
*.png.backup
# ── Local Playwright outputs ─────────────────────────────────
.playwright-mcp/
portal-tiles*.png
portal-cta*.png
# ── OS / editor ──────────────────────────────────────────────
.DS_Store
Thumbs.db
*.swp
*~
.idea/
.vscode/
# ── Logs ─────────────────────────────────────────────────────
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

339
AI_PORTAL_HANDOFF.md Normal file
View File

@@ -0,0 +1,339 @@
# Internal AI Portal — Project Handoff
**Status:** Architecture & vendor selection complete. Ready for build.
**Audience:** Local coding agent + project owner.
**Goal:** Build an internal AI landing page for company employees that combines guided "AI apps" (tile-based workflows) with a full-featured chat fallback, all routed through company API keys with per-user cost tracking and budget enforcement.
---
## 1. Product vision
A single internal URL employees go to. They see:
- **A grid of tiles**, each a guided "AI app" for a specific task (legal-notice check, contract summarizer, invoice extractor, translator, etc.). These are for colleagues who don't want to write prompts — fill a form, get an answer.
- **One special tile labeled "Open Chat"** that drops them into a full ChatGPT/Claude-equivalent chat: file uploads (Excel, PDF, images), code execution sandbox, multi-turn, switch between models mid-conversation.
- All API calls go through company-owned keys. Employees don't pay; the company does. In return, every call is logged, costed, and budget-capped per user.
**Non-goals (for v1):**
- Customer-facing product (internal only).
- Multi-tenant SaaS.
- Replacing existing tools that aren't AI-related.
---
## 2. Architecture overview
Composition of three FOSS components plus a thin custom landing page. No single FOSS project covers all requirements — combining them is the production-ready path.
```
┌────────────────────────────────────┐
│ Custom landing page (tiles UI) │
│ - Auth via company SSO (OIDC) │
│ - Renders tiles + "Open Chat" │
└────────────┬───────────────────────┘
┌──────────────────────┼──────────────────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌──────────────┐
│ Dify │ │ Open WebUI │ │ Other tile │
│ (workflow │ │ (chat + │ │ apps (links │
│ apps) │ │ sandbox) │ │ or iframes)│
└─────┬──────┘ └─────┬──────┘ └──────┬───────┘
│ │ │
└──────────────────────┼───────────────────────┘
┌──────────────────────────────┐
│ LiteLLM proxy gateway │
│ - Unified OpenAI-format │
│ - Per-user virtual keys │
│ - Budget enforcement │
│ - Cost & token logging │
└──────────────┬───────────────┘
┌──────────────────────────────┐
│ Langfuse (observability) │
│ - Traces, dashboards │
│ - Per-user analytics │
│ (consumes LiteLLM logs) │
└──────────────┬───────────────┘
OpenAI / Anthropic / Google / Azure APIs
```
### Component responsibilities
| Component | Role | License |
|-----------|------|---------|
| **Landing page** | Tile UI, SSO entry point, hands users off to specific tools | Custom (we own it) |
| **Dify** | Hosts the predefined "apps" — visual workflow builder, each app is a workflow | Apache 2.0 + extras (logo restriction; OK for internal single-tenant) |
| **Open WebUI** | The "Open Chat" experience — full ChatGPT-equivalent UI with file uploads & code interpreter | BSD-3-Clause |
| **gVisor code execution add-on** | Sandboxed Python/Bash for Open WebUI (Excel processing, charts, etc.) | Apache 2.0 |
| **LiteLLM** | Single API gateway for all providers, virtual keys per user, budget enforcement, cost tracking | MIT |
| **Langfuse** | Observability dashboards on top of LiteLLM | MIT (with self-host caveats — see §9) |
### Why this split
- **Dify alone** has tile-based apps but its chat is weaker than dedicated chat UIs and its code interpreter doesn't match ChatGPT's Excel-processing experience.
- **Open WebUI alone** is chat-first; building 50 guided "apps" inside it means custom Functions/Pipelines code per app, which is harder to maintain than Dify's visual workflows.
- **LibreChat** was considered but rejected: its built-in code interpreter is a paid SaaS service, which fails the "as capable as official chat interfaces" requirement out of the box. Open WebUI's gVisor sandbox is fully free.
- **LiteLLM** is the only piece that gives us per-user budgets and cost tracking across both Dify and Open WebUI, since both can be configured to use it as their "OpenAI-compatible" provider.
---
## 3. User flows
### Flow A — colleague who doesn't want to write prompts
1. Opens portal → sees tile grid.
2. Clicks "Legal Notice Check" tile → opens Dify-hosted app in iframe or new tab.
3. Form: paste notice text, select jurisdiction, click Submit.
4. Dify workflow runs → output displayed.
5. All LLM calls inside the workflow went through LiteLLM → cost attributed to this user.
### Flow B — colleague who wants a real chat
1. Opens portal → clicks "Open Chat" tile.
2. Lands in Open WebUI session (already authenticated via SSO).
3. Uploads `q3-sales.xlsx`, asks "summarize regional performance and draw a bar chart."
4. Model writes Python → gVisor sandbox executes → result + chart appear inline.
5. User can switch model mid-conversation (Claude → GPT-5 → local).
6. All calls flow through LiteLLM → costed to this user, budget enforced.
### Flow C — admin
1. Opens LiteLLM admin UI → sees per-user spend, sets/adjusts budgets.
2. Opens Langfuse → sees traces, prompt analytics, error rates.
3. Opens Dify admin → adds a new workflow → it appears as a new tile on the landing page (via config update).
---
## 4. Repository layout (proposed)
```
ai-portal/
├── README.md
├── docker-compose.yml # All services
├── .env.example
├── landing/ # Custom landing page (Next.js or similar)
│ ├── package.json
│ ├── src/
│ │ ├── pages/index.tsx # Tile grid
│ │ ├── lib/auth.ts # OIDC client
│ │ └── data/apps.json # Tile definitions
│ └── Dockerfile
├── litellm/
│ ├── config.yaml # Models, virtual keys, budgets
│ └── README.md
├── openwebui/
│ ├── env.example
│ ├── functions/ # Custom OWUI functions if needed
│ └── tools/
│ └── run_code.py # gVisor sandbox tool (from EtiennePerot/safe-code-execution)
├── dify/
│ ├── env.example
│ ├── workflows/ # Exported workflow DSL files (one per tile app)
│ │ ├── legal-notice-check.yml
│ │ └── ...
│ └── README.md
├── langfuse/
│ └── env.example
├── infra/
│ ├── traefik/ # Reverse proxy + TLS
│ │ └── traefik.yml
│ └── backup/
│ └── backup.sh
└── docs/
├── ARCHITECTURE.md # This document, eventually
├── ADDING_NEW_APP.md # Runbook for adding a tile
└── ONBOARDING.md # End-user docs
```
---
## 5. Build phases
### Phase 0 — Local dev environment (Day 1)
Goal: all four services running locally via docker-compose, talking to each other.
1. Bootstrap repo with the layout above.
2. Write `docker-compose.yml` with services: `litellm`, `langfuse`, `openwebui`, `dify-api`, `dify-web`, `dify-worker`, `redis`, `postgres`, `traefik`, `landing`.
3. Each service gets its own subnet + `.env` file. No secrets in git.
4. Verify: each service reachable at `https://<service>.localhost` via Traefik with self-signed certs.
**Acceptance:** `docker compose up` brings everything up clean. Each service's UI loads.
### Phase 1 — LiteLLM gateway (Day 2)
Goal: all model calls go through LiteLLM. Cost tracking works.
1. Configure `litellm/config.yaml` with at minimum: OpenAI, Anthropic, Google. Add Azure if used.
2. Set up a master key + per-user virtual keys (for now: 2 test users).
3. Set test budget: $5/user/month, hard cap.
4. Smoke test: `curl` a chat completion through LiteLLM → verify it appears in LiteLLM's spend log.
5. Verify budget enforcement: temporarily lower limit, blast 1000 tokens, confirm 429.
**Acceptance:** LiteLLM admin UI shows per-user spend in real time. Budget limits actually block.
LiteLLM docs: https://docs.litellm.ai/docs/proxy/quick_start
### Phase 2 — Open WebUI as the chat tile (Days 3-4)
Goal: full chat UI with file upload + Excel processing.
1. Configure Open WebUI to use LiteLLM as its OpenAI-compatible endpoint.
2. Set up SSO (OIDC) so user identity flows through.
3. Map OWUI users → LiteLLM virtual keys (so per-user budgets work). Two options:
- Single LiteLLM key, OWUI passes user ID as `X-LiteLLM-User-Id` header — requires LiteLLM tag-based tracking.
- Per-user LiteLLM keys, OWUI feature for per-user provider keys. Investigate which is cleaner.
4. Install the gVisor code execution function and tool from `EtiennePerot/safe-code-execution`. Configure the OWUI container with sandboxing prerequisites (privileged or specific capabilities — see that repo's setup docs).
5. Test: upload an XLSX, ask the model to analyze and chart it. Verify code runs in sandbox, chart appears inline, no code escapes the sandbox.
**Acceptance:** A non-technical user can upload a file and ask "what's in this spreadsheet" and get a useful answer with a chart. Cost shows up in LiteLLM tied to that user.
References:
- Open WebUI docs: https://docs.openwebui.com/
- Code exec add-on: https://github.com/EtiennePerot/safe-code-execution
- Pyodide alternative (no Docker privilege needed but limited libs): https://docs.openwebui.com/features/chat-conversations/chat-features/code-execution/python/
### Phase 3 — Dify for workflow apps (Days 5-7)
Goal: at least 3 working "apps" hosted in Dify and reachable via shareable URLs.
1. Configure Dify's model providers to point at LiteLLM (NOT directly at OpenAI/Anthropic). This is critical — otherwise Dify calls bypass cost tracking.
2. Create the first 3 workflows as proof of concept:
- **Legal Notice Check** (input: text + jurisdiction → output: risk summary)
- **Document Summarizer** (input: file → output: bullet summary)
- **Email Drafter** (input: bullets → output: polished email)
3. For each workflow, enable the "share as web app" feature, get a URL.
4. Decide tile-to-workflow URL mapping format: store in `landing/src/data/apps.json`.
**Acceptance:** Each app works end-to-end via its share URL. Each invocation shows up in LiteLLM spend log attributed to a user (this requires Dify to forward user identity — research how, may need a custom API gateway proxy in front of Dify share URLs).
References:
- Dify self-hosting: https://docs.dify.ai/getting-started/install-self-hosted/docker-compose
- Dify license: https://github.com/langgenius/dify/blob/main/LICENSE (note logo restriction)
### Phase 4 — Landing page (Days 8-10)
Goal: the front door.
1. Next.js (or Astro, SvelteKit — pick what the team knows) app with:
- OIDC login.
- Tile grid, data-driven from `apps.json`.
- Each tile: icon, title, 1-line description, click-through to either a Dify share URL or `/chat` (Open WebUI).
- Search/filter for when there are 50 tiles.
2. Make `apps.json` editable without redeploy: read it from a mounted volume or fetch from Dify's app list API on render.
3. Branding: company logo, color scheme.
4. Add a simple "Costs" link visible to admins only → embeds Langfuse or LiteLLM dashboard.
**Acceptance:** A new employee with SSO access lands on the page, sees tiles, can click into a workflow or open chat without any extra login prompt.
### Phase 5 — Observability & ops (Days 11-12)
1. Stand up Langfuse, configure LiteLLM to ship traces to it.
2. Build a couple of saved dashboards: total spend per day, top 10 users, top 10 apps.
3. Set up alerts: Slack/email when monthly spend hits 80% of cap.
4. Backup script for: Postgres (Dify, Langfuse, Open WebUI), Redis snapshots, Dify workflow exports.
5. Restore drill: spin up fresh stack from backups in <30 min.
### Phase 6 — Hardening & rollout (Days 13-15)
1. Move from self-signed to real TLS (Let's Encrypt via Traefik).
2. Lock down: only company SSO group X can authenticate.
3. Audit log review: confirm every LLM call has user attribution.
4. Doc: `ONBOARDING.md` for end users, `ADDING_NEW_APP.md` for the team that maintains workflows.
5. Pilot with 5-10 users for a week. Iterate. Then announce internally.
---
## 6. Critical decisions to make before coding
These should be answered before Phase 0:
1. **Where will this run?** On-prem, AWS, Azure, GCP? Affects networking, secrets management, and TLS.
2. **Which SSO?** Microsoft Entra ID (Azure AD), Google Workspace, Okta, Keycloak? Open WebUI, Dify, and Langfuse all support OIDC; landing page will too.
3. **What's the realistic budget for v1?** Affects: how many models we expose (GPT-5 + Claude Opus = expensive), default per-user budget, alert thresholds.
4. **Who owns this long-term?** The team adding new workflows in Dify is different from the team maintaining the platform. Make this explicit.
5. **Data sensitivity?** If employees may paste PII or confidential data, need to: (a) prefer Anthropic/OpenAI Zero Data Retention agreements, (b) consider Azure OpenAI for stronger contractual posture, (c) document an acceptable use policy.
6. **Logo customization for Dify.** Internal-only single-tenant deployments are fine keeping the Dify logo. If branding is required, pricing requires contacting business@dify.ai directly. Recommend: keep logo for v1, revisit if leadership pushes back.
---
## 7. Risks & mitigations
| Risk | Mitigation |
|------|------------|
| Cost runs away on day 1 | LiteLLM hard budgets per user enforced from the start, low default ($10-20/user/month) |
| Sandbox escape in code interpreter | gVisor is what ChatGPT uses; combined with container isolation, lowest-feasible risk for this use case. No internet egress from sandbox by default. |
| Dify license violation | Stay single-tenant, keep logo (or pay for license). Document this in `LICENSE_NOTES.md` |
| Sensitive data leakage to providers | Configure providers with ZDR / no-training. Add a banner on the landing page reminding users not to paste secrets. Optionally: a content filter at the LiteLLM layer (regex for credit cards, secrets) that strips/blocks. |
| Vendor lock-in to Dify | Workflows are exportable as YAML. Periodic export commit to git keeps them portable. |
| User identity drift across services | Single OIDC issuer, all services configured to use it, scripted "sync user LiteLLM virtual key" on first login. |
| Open WebUI gVisor sandbox needs privileged Docker | If host policy blocks privileged containers, fall back to Pyodide (browser-based, no privilege needed, narrower library set). Document the trade-off. |
---
## 8. Acceptance criteria for v1
The v1 ships when all of these are true:
- [ ] An employee with SSO can reach the portal at `https://ai.<company>.<tld>` and see tiles.
- [ ] At least 5 working "apps" as tiles, plus the chat tile.
- [ ] Chat supports: Excel/CSV/PDF upload, code execution, image input, model switching mid-conversation.
- [ ] Every LLM call appears in LiteLLM logs with the calling user's identity.
- [ ] Per-user monthly budget enforces (verified by integration test).
- [ ] Admin can see a per-user, per-app spend dashboard.
- [ ] Adding a new "app" tile takes <30 minutes (build workflow in Dify, add row to `apps.json`).
- [ ] Documented disaster recovery: backups + restore drill executed once.
- [ ] Acceptable use policy linked from the landing page.
---
## 9. Known unknowns / research items
These need a spike before committing:
1. **User identity propagation Dify → LiteLLM.** Dify's "share as web app" mode may not pass end-user identity to its LLM calls. May need to either: (a) put a small reverse proxy in front of Dify share URLs that injects a header, or (b) provision per-user Dify accounts and per-user provider keys in Dify (expensive ops cost). Spike this in Phase 3.
2. **Open WebUI per-user provider keys vs tag-based attribution.** Both work; pick based on which has cleaner UX for budget reporting.
3. **Langfuse self-hosting license.** Langfuse self-hosted has tiers; the FOSS core covers traces but some advanced features are commercial. Confirm the FOSS-core features are sufficient before depending on it. If not, fallback is LiteLLM's built-in dashboard (less pretty but free and sufficient for v1).
4. **Where do uploaded files live?** Open WebUI stores them; depending on data classification, may need to mount an encrypted volume or shorten retention.
5. **Rate limits per provider.** With many users behind one company key, OpenAI/Anthropic org-level RPM/TPM limits may bite. LiteLLM supports load balancing across multiple keys plan for this if rollout is wide.
---
## 10. Quick start for the local agent
When you sit down with the local agent, suggested first prompt:
> Read `AI_PORTAL_HANDOFF.md` in this repo. We're building Phase 0 today: the docker-compose skeleton. Set up the directory structure from Section 4, create a `docker-compose.yml` that starts LiteLLM, Open WebUI, Dify (api + web + worker), Postgres, Redis, Langfuse, Traefik, and a placeholder landing page service. Use environment variables for all secrets, with a `.env.example` checked in. Don't configure model providers yet — just get all containers healthy and reachable through Traefik with self-signed TLS at `*.localhost`. Stop and ask before proceeding to Phase 1.
After Phase 0 works, hand the agent Phase 1, 2, etc. one at a time. Don't let it run more than one phase ahead these are integration-heavy and a bad assumption early on will cascade.
---
## Appendix A — Reference links
- Dify: https://github.com/langgenius/dify
- Open WebUI: https://github.com/open-webui/open-webui
- Open WebUI code exec docs: https://docs.openwebui.com/features/chat-conversations/chat-features/code-execution/
- gVisor sandbox add-on: https://github.com/EtiennePerot/safe-code-execution
- LiteLLM: https://github.com/BerriAI/litellm
- LiteLLM proxy docs: https://docs.litellm.ai/docs/proxy/quick_start
- Langfuse: https://github.com/langfuse/langfuse
## Appendix B — Why not LibreChat
Considered. Rejected because LibreChat's built-in Code Interpreter is a paid SaaS service rather than a self-hostable component. The "as capable as official chat interfaces" requirement specifically calls for in-chat code execution and file processing; making that a paid dependency contradicts the "free and open source" goal. Open WebUI's gVisor add-on covers the same ground and is fully Apache-2.0.
## Appendix C — Why not building from scratch
Considered. Rejected for v1 because:
- A production-quality multi-provider chat UI with file uploads, model switching, conversation history, and a sandboxed code interpreter is roughly a year of focused engineering. Open WebUI gives us this for free.
- A workflow builder that non-engineers can use to add new "apps" is similarly large in scope. Dify gives us this for free.
- Custom code is justified only where no FOSS option exists: the landing page (small), the auth glue, and the per-user billing reconciliation.
If after running v1 for a few months we find the FOSS pieces don't fit, we can replace one component at a time without throwing out the whole stack that's the benefit of the gateway architecture.

78
README.md Normal file
View File

@@ -0,0 +1,78 @@
# AI Portal
Internal landing page that fronts a stack of AI tools (chat + workflow apps),
all routed through company API keys with per-user budgets and observability.
See `AI_PORTAL_HANDOFF.md` for full architecture and rationale.
## Phase 0 — what's running
- **Landing page** (Next.js 16 + Auth.js v5) — this repo, `landing/`
- **Open WebUI** — already running on host (`127.0.0.1:8080`)
- **Dify stack** — already running (`docker_default` network, nginx on `:443/:8090`)
- **LiteLLM** — already running on `localai` network (`:4000`)
- **Langfuse** — not yet (compose stub left in `docker-compose.yml`)
## Local dev
```bash
cd landing
cp ../.env.example ../.env # then edit secrets
pnpm install
pnpm dev # http://localhost:3000
```
Set at minimum:
- `AUTH_SECRET``openssl rand -base64 32`
- `DEV_PORTAL_PASSWORD` — any string; gates the dev login
## Production-style run (Docker)
From the `portal/` directory:
```bash
docker compose up -d --build
```
The landing service binds to `127.0.0.1:3010`. Caddy on the host fronts it at
`https://ai.klas.chat`.
## Auth
Two providers, both wired through Auth.js v5:
1. **Microsoft Entra ID** — production. Activated automatically when
`AUTH_MICROSOFT_ENTRA_ID_ID` and `AUTH_MICROSOFT_ENTRA_ID_SECRET` are set.
2. **Dev Credentials** — single shared password from `DEV_PORTAL_PASSWORD`.
Used only while Entra is not provisioned. Both can coexist; the login page
shows whichever is configured.
All routes except `/login` and `/api/auth/*` are gated by `proxy.ts`.
## Adding a new tile
Edit `landing/src/data/apps.json` and add an entry:
```json
{
"id": "my-app",
"title": "My App",
"description": "What it does in one line.",
"category": "Documents",
"icon": "FileText",
"accent": "blue",
"href": "https://dify.klas.chat/app/<id>"
}
```
Icon names come from [lucide-react](https://lucide.dev). Accents:
`violet`, `blue`, `cyan`, `emerald`, `amber`, `rose`. Use `"href": "#"` to
mark a tile as "Coming soon".
## What's next (post Phase 0)
1. Wire Open WebUI to use LiteLLM as its OpenAI-compatible endpoint.
2. Configure Dify model providers to point at LiteLLM (so spend is attributed).
3. Stand up Langfuse (uncomment in `docker-compose.yml`).
4. Map per-user identity → LiteLLM virtual keys.
5. Provision Microsoft Entra app registration; flip prod auth on.

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

View File

@@ -0,0 +1,170 @@
app:
description: 'Vlož text dokumentu, získáš stručné shrnutí v 5 bodech.'
icon: 📄
icon_background: '#E0F2FE'
mode: workflow
name: Shrnutí dokumentu
use_icon_as_answer_icon: false
kind: app
version: 0.4.0
workflow:
conversation_variables: []
environment_variables: []
features:
file_upload:
enabled: false
opening_statement: ''
retriever_resource:
enabled: false
sensitive_word_avoidance:
enabled: false
speech_to_text:
enabled: false
suggested_questions: []
suggested_questions_after_answer:
enabled: false
text_to_speech:
enabled: false
language: ''
voice: ''
graph:
edges:
- data:
isInIteration: false
isInLoop: false
sourceType: start
targetType: llm
id: start-to-llm
source: 'start_node'
sourceHandle: source
target: 'llm_node'
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
isInLoop: false
sourceType: llm
targetType: end
id: llm-to-end
source: 'llm_node'
sourceHandle: source
target: 'end_node'
targetHandle: target
type: custom
zIndex: 0
nodes:
- data:
desc: ''
selected: false
title: Vstup
type: start
variables:
- label: Text dokumentu
max_length: 50000
options: []
required: true
type: paragraph
variable: dokument
- label: Styl shrnutí
max_length: 100
options:
- Stručné body (do 5)
- Detailní souhrn (1 odstavec)
- TL;DR (1 věta)
required: true
type: select
variable: styl
height: 130
id: 'start_node'
position:
x: 30
y: 200
positionAbsolute:
x: 30
y: 200
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 244
- data:
context:
enabled: false
variable_selector: []
desc: ''
model:
completion_params:
temperature: 0.3
mode: chat
name: gpt-5-mini
provider: langgenius/openai_api_compatible/openai_api_compatible
prompt_template:
- id: sys-prompt
role: system
text: |-
Jsi asistent, který vytváří stručná česká shrnutí.
Pravidla:
- Odpovídej VÝHRADNĚ česky.
- Drž se faktů z poskytnutého textu, nevymýšlej.
- Zachovej hlavní tezi, klíčová čísla, jména a termíny.
- Pokud je text příliš krátký na shrnutí, řekni to.
Styl výstupu zvolí uživatel — řiď se přesně tím, co vybral.
- id: user-prompt
role: user
text: |-
Styl: {{#start_node.styl#}}
Text k shrnutí:
---
{{#start_node.dokument#}}
---
selected: false
title: Shrnutí (LLM)
type: llm
variables: []
vision:
enabled: false
height: 130
id: 'llm_node'
position:
x: 350
y: 200
positionAbsolute:
x: 350
y: 200
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 244
- data:
desc: ''
outputs:
- value_selector:
- 'llm_node'
- text
value_type: string
variable: shrnuti
selected: false
title: Výstup
type: end
height: 90
id: 'end_node'
position:
x: 670
y: 200
positionAbsolute:
x: 670
y: 200
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 244
viewport:
x: 0
y: 0
zoom: 0.8

60
docker-compose.yml Normal file
View File

@@ -0,0 +1,60 @@
services:
landing:
build:
context: ./landing
dockerfile: Dockerfile
container_name: ai-portal-landing
restart: unless-stopped
ports:
- "127.0.0.1:3010:3010"
- "100.95.104.123:3010:3010" # Tailscale (t6)
environment:
AUTH_SECRET: ${AUTH_SECRET:?set AUTH_SECRET in .env}
AUTH_TRUST_HOST: "true"
NEXTAUTH_URL: ${NEXTAUTH_URL:-https://ai.klas.chat}
DEV_PORTAL_PASSWORD: ${DEV_PORTAL_PASSWORD:-}
AUTH_MICROSOFT_ENTRA_ID_ID: ${AUTH_MICROSOFT_ENTRA_ID_ID:-}
AUTH_MICROSOFT_ENTRA_ID_SECRET: ${AUTH_MICROSOFT_ENTRA_ID_SECRET:-}
AUTH_MICROSOFT_ENTRA_ID_ISSUER: ${AUTH_MICROSOFT_ENTRA_ID_ISSUER:-}
networks:
- localai
# Langfuse observability stack (optional for v1 — uncomment when ready).
# Reference: https://langfuse.com/self-hosting/docker-compose
#
# langfuse-db:
# image: postgres:16
# container_name: ai-portal-langfuse-db
# restart: unless-stopped
# environment:
# POSTGRES_USER: langfuse
# POSTGRES_PASSWORD: ${LANGFUSE_DB_PASSWORD}
# POSTGRES_DB: langfuse
# volumes:
# - langfuse-db:/var/lib/postgresql/data
# networks:
# - localai
#
# langfuse:
# image: langfuse/langfuse:2
# container_name: ai-portal-langfuse
# restart: unless-stopped
# depends_on:
# - langfuse-db
# ports:
# - "127.0.0.1:3011:3000"
# environment:
# DATABASE_URL: postgresql://langfuse:${LANGFUSE_DB_PASSWORD}@langfuse-db:5432/langfuse
# NEXTAUTH_URL: https://langfuse.klas.chat
# NEXTAUTH_SECRET: ${LANGFUSE_NEXTAUTH_SECRET}
# SALT: ${LANGFUSE_SALT}
# TELEMETRY_ENABLED: "false"
# networks:
# - localai
# volumes:
# langfuse-db:
networks:
localai:
external: true

View File

@@ -0,0 +1,3 @@
LITELLM_BASE_URL=http://host.docker.internal:4000/v1
LITELLM_API_KEY=sk-...
LLM_MODEL=anthropic/claude-sonnet-4-20250514

47
dwg-counting/Dockerfile Normal file
View File

@@ -0,0 +1,47 @@
# Stage 1: compile LibreDWG (provides dwgread 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
# poppler-utils for pdf2image; cairo + pango for cairosvg
RUN apt-get update && apt-get install -y --no-install-recommends \
poppler-utils \
libgl1 \
libcairo2 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
&& 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/dwg-counting
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

286
dwg-counting/counting.py Normal file
View File

@@ -0,0 +1,286 @@
"""Shape-based symbol counting via contour matching.
Why not cv2.matchTemplate: CAD symbols are usually thin-line drawings on a
white background (~10-20% ink). Normalized cross-correlation gives spuriously
high scores for any sparse-ink region (e.g. wall edges), producing false
positives everywhere.
Approach used here:
1. Binarize template + drawing to "ink" maps.
2. Find external contours in both.
3. Use the template's main contour as a shape reference.
4. For every contour in the drawing, compare via cv2.matchShapes (Hu moments
— invariant to scale, rotation, translation).
5. Filter by area ratio (similar size to template, allowing ±factor).
6. Keep contours below a shape-distance threshold.
This is the classic approach to CAD symbol takeoff.
"""
import logging
from pathlib import Path
from typing import Iterable
import cv2
import numpy as np
logger = logging.getLogger(__name__)
# Template matching: higher = stricter. 0.65 is permissive, 0.85 strict.
DEFAULT_THRESHOLD = 0.7
def _prep(img_path: Path) -> np.ndarray:
"""Binarize to 'ink vs not-ink'.
No dilation — for contour-based matching we need lines to stay narrow
so distinct symbols don't merge into one mega-contour through CAD's
dense linework.
"""
arr = cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE)
if arr is None:
raise RuntimeError(f"Could not load {img_path}")
_, ink = cv2.threshold(arr, 245, 255, cv2.THRESH_BINARY_INV)
return ink
def _crop_to_content(template: np.ndarray, bg_threshold: int = 240) -> np.ndarray:
"""Crop a template to its non-background bounding box.
After _prep the template is BINARY (ink=255, bg=0). So 'content' is
everything with value > 0. The bg_threshold param is kept for the
grayscale case for backward compat.
"""
if template.max() <= 1 or template.min() == 0 and template.max() == 255:
# Binary map: any non-zero pixel is ink
mask = template > 0
else:
mask = template < bg_threshold
if not mask.any():
return template
ys, xs = np.where(mask)
y0, y1 = ys.min(), ys.max() + 1
x0, x1 = xs.min(), xs.max() + 1
pad = 2
y0 = max(0, y0 - pad)
x0 = max(0, x0 - pad)
y1 = min(template.shape[0], y1 + pad)
x1 = min(template.shape[1], x1 + pad)
return template[y0:y1, x0:x1]
def _nms(boxes: list[tuple], scores: list[float], overlap_thresh: float = 0.3) -> list[int]:
"""Non-max suppression. Returns indices of kept boxes."""
if not boxes:
return []
boxes_arr = np.array(boxes, dtype=np.float32)
x1 = boxes_arr[:, 0]
y1 = boxes_arr[:, 1]
x2 = boxes_arr[:, 2]
y2 = boxes_arr[:, 3]
areas = (x2 - x1) * (y2 - y1)
order = np.argsort(scores)[::-1]
keep = []
while order.size > 0:
i = order[0]
keep.append(int(i))
xx1 = np.maximum(x1[i], x1[order[1:]])
yy1 = np.maximum(y1[i], y1[order[1:]])
xx2 = np.minimum(x2[i], x2[order[1:]])
yy2 = np.minimum(y2[i], y2[order[1:]])
w = np.maximum(0.0, xx2 - xx1)
h = np.maximum(0.0, yy2 - yy1)
inter = w * h
union = areas[i] + areas[order[1:]] - inter
iou = inter / np.maximum(union, 1e-6)
order = order[1:][iou <= overlap_thresh]
return keep
MAX_DRAWING_PX = 9000 # effectively no downscale for typical A1/A0
def debug_template(template_path: Path, drawing_path: Path) -> dict:
"""Return diagnostics for tuning a symbol template."""
template = _prep(template_path)
template_cropped = _crop_to_content(template)
drawing = _prep(drawing_path)
info = {
"template_size": list(template.shape),
"template_cropped_size": list(template_cropped.shape),
"template_ink_pixels": int((template_cropped > 0).sum()
if template_cropped.max() == 255 and template_cropped.min() == 0
else (template_cropped < 240).sum()),
"template_total_pixels": int(template_cropped.size),
"drawing_size": list(drawing.shape),
}
if min(template_cropped.shape) < 8:
info["error"] = "template too small after content crop"
return info
# Scale sweep — finds the size at which template matches best
scale_scan = []
best_overall = -1.0
for scale in (0.15, 0.25, 0.35, 0.5, 0.7, 0.85, 1.0, 1.2, 1.5, 2.0):
nw = max(8, int(template_cropped.shape[1] * scale))
nh = max(8, int(template_cropped.shape[0] * scale))
if nh > drawing.shape[0] or nw > drawing.shape[1]:
continue
tmpl = cv2.resize(template_cropped, (nw, nh), interpolation=cv2.INTER_AREA)
res = cv2.matchTemplate(drawing, tmpl, cv2.TM_CCOEFF_NORMED)
m = float(res.max())
scale_scan.append({
"scale": scale,
"template_px": [nh, nw],
"max_score": round(m, 3),
"count_at_0.7": int((res >= 0.7).sum()),
"count_at_0.6": int((res >= 0.6).sum()),
})
if m > best_overall:
best_overall = m
info["max_score"] = best_overall
info["scale_scan"] = scale_scan
# Threshold counts at scale=1.0 for reference
result = cv2.matchTemplate(drawing, template_cropped, cv2.TM_CCOEFF_NORMED)
info["matches_at_threshold"] = {
"0.60": int((result >= 0.60).sum()),
"0.70": int((result >= 0.70).sum()),
"0.75": int((result >= 0.75).sum()),
"0.80": int((result >= 0.80).sum()),
"0.85": int((result >= 0.85).sum()),
"0.90": int((result >= 0.90).sum()),
}
return info
def _merge_template_contour(ink: np.ndarray) -> np.ndarray | None:
"""Combine all strokes of a template into one contour via closing.
Returns the biggest connected component. The template is small enough that
closing safely merges the line + arc of symbols like '-C' into one shape
without affecting matching against the drawing.
"""
closed = cv2.morphologyEx(ink, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
contours, _ = cv2.findContours(closed, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
return max(contours, key=cv2.contourArea)
def count_template(
template_path: Path,
drawing_path: Path,
threshold: float = DEFAULT_THRESHOLD,
rotations: Iterable[int] = (0, 90, 180, 270),
scales: Iterable[float] = (0.6, 0.8, 1.0, 1.25, 1.5),
exclude_box: tuple | None = None,
area_tolerance: float = 200.0,
mirror: bool = True,
) -> dict:
"""Multi-scale, multi-rotation template matching (TM_SQDIFF_NORMED).
With mirror=True the template is also tested flipped horizontally — at
each rotation — so we catch mirrored instances (e.g. a socket facing
left vs facing right).
"""
template = _prep(template_path)
template = _crop_to_content(template)
drawing = _prep(drawing_path)
coord_scale = 1.0
if max(drawing.shape) > MAX_DRAWING_PX:
coord_scale = max(drawing.shape) / MAX_DRAWING_PX
new_w = int(drawing.shape[1] / coord_scale)
new_h = int(drawing.shape[0] / coord_scale)
drawing = cv2.resize(drawing, (new_w, new_h), interpolation=cv2.INTER_AREA)
if exclude_box is not None:
ex_x, ex_y, ex_w, ex_h = exclude_box
ex_x = int(ex_x / coord_scale)
ex_y = int(ex_y / coord_scale)
ex_w = int(ex_w / coord_scale)
ex_h = int(ex_h / coord_scale)
h, w = drawing.shape
ex_x = max(0, min(w, ex_x))
ex_y = max(0, min(h, ex_y))
ex_x2 = max(0, min(w, ex_x + ex_w))
ex_y2 = max(0, min(h, ex_y + ex_h))
drawing[ex_y:ex_y2, ex_x:ex_x2] = 0
logger.info("Masked legend region (%d,%d,%d,%d)", ex_x, ex_y, ex_w, ex_h)
if min(template.shape) < 8 or int((template > 0).sum()) < 5:
logger.warning("Template too small / empty after preprocessing")
return {"count": 0, "matches": [], "threshold_used": threshold}
all_boxes: list[tuple] = []
all_scores: list[float] = []
# Build the variants we'll search with: rotations × (original, mirrored)
variants = [(template, False)]
if mirror:
variants.append((cv2.flip(template, 1), True))
def _rotate(img, angle_deg):
if angle_deg == 0:
return img
if angle_deg in (90, 180, 270):
return np.rot90(img, k=angle_deg // 90)
# Arbitrary angle — rotate with bbox expansion + white fill
h, w = img.shape[:2]
center = (w / 2, h / 2)
M = cv2.getRotationMatrix2D(center, angle_deg, 1.0)
cos = abs(M[0, 0]); sin = abs(M[0, 1])
new_w = int(h * sin + w * cos)
new_h = int(h * cos + w * sin)
M[0, 2] += new_w / 2 - center[0]
M[1, 2] += new_h / 2 - center[1]
return cv2.warpAffine(img, M, (new_w, new_h),
flags=cv2.INTER_NEAREST, borderValue=0)
# Build the full job list (one entry per rot×scale×mirror)
jobs_list = []
for variant, _ in variants:
for rot in rotations:
base = _rotate(variant, rot)
for scale in scales:
new_w = max(8, int(base.shape[1] * scale))
new_h = max(8, int(base.shape[0] * scale))
if new_h > drawing.shape[0] or new_w > drawing.shape[1]:
continue
tmpl = cv2.resize(base, (new_w, new_h),
interpolation=cv2.INTER_AREA)
jobs_list.append((tmpl, new_w, new_h))
def _run(args):
tmpl, new_w, new_h = args
sq = cv2.matchTemplate(drawing, tmpl, cv2.TM_SQDIFF_NORMED)
cc = cv2.matchTemplate(drawing, tmpl, cv2.TM_CCOEFF_NORMED)
sim = np.maximum(1.0 - sq, cc)
ys, xs = np.where(sim >= threshold)
out = []
for y, x in zip(ys, xs):
out.append((float(x), float(y), float(x + new_w),
float(y + new_h), float(sim[y, x])))
return out
# Threading: cv2.matchTemplate releases the GIL, so threads give real speedup
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as ex:
for result in ex.map(_run, jobs_list):
for x0, y0, x1, y1, score in result:
all_boxes.append((x0, y0, x1, y1))
all_scores.append(score)
if not all_boxes:
return {"count": 0, "matches": [], "threshold_used": threshold}
keep = _nms(all_boxes, all_scores, overlap_thresh=0.3)
matches = []
for i in keep:
x0, y0, x1, y1 = all_boxes[i]
matches.append({
"x": int(x0 * coord_scale),
"y": int(y0 * coord_scale),
"w": int((x1 - x0) * coord_scale),
"h": int((y1 - y0) * coord_scale),
"score": round(all_scores[i], 3),
})
return {"count": len(matches), "matches": matches, "threshold_used": threshold}

View File

@@ -0,0 +1,24 @@
services:
dwg-counting:
build: .
container_name: dwg-counting
restart: unless-stopped
ports:
- "127.0.0.1:3026: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:
- dwg-counting-data:/tmp/dwg-counting
volumes:
dwg-counting-data:
networks:
localai:
external: true

View File

@@ -0,0 +1,41 @@
"""Export symbol count results to XLSX."""
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
def export_to_excel(results: list[dict], filename: str, out_path: str) -> None:
"""results = [{id, description, count, confidence, notes}, ...]"""
wb = Workbook()
ws = wb.active
ws.title = "Symboly"
headers = ["Symbol", "Popis", "Počet", "Spolehlivost", "Poznámka"]
ws.append(headers)
for cell in ws[1]:
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="2563EB")
cell.alignment = Alignment(horizontal="center")
for r in results:
ws.append([
r.get("id", ""),
r.get("description", ""),
r.get("count", 0),
r.get("confidence", ""),
r.get("notes", ""),
])
# Auto-width
widths = [16, 60, 10, 14, 40]
for i, w in enumerate(widths, 1):
ws.column_dimensions[chr(64 + i)].width = w
# Total row
total = sum(int(r.get("count", 0) or 0) for r in results)
ws.append([])
last = ws.max_row + 1
ws.append(["", f"Celkem ({filename})", total, "", ""])
ws[f"B{last+1}"].font = Font(bold=True)
ws[f"C{last+1}"].font = Font(bold=True)
wb.save(out_path)

Binary file not shown.

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

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

View File

@@ -0,0 +1,93 @@
"""Render annotated PDF with color-coded symbol match highlights."""
import logging
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
logger = logging.getLogger(__name__)
# Distinct visually-separable colors (RGB) for up to 12 symbol types
PALETTE = [
(228, 26, 28), # red
(55, 126, 184), # blue
(77, 175, 74), # green
(152, 78, 163), # purple
(255, 127, 0), # orange
(0, 184, 184), # teal
(247, 129, 191), # pink
(153, 153, 0), # olive
(166, 86, 40), # brown
(102, 102, 102), # grey
(0, 0, 0), # black
(190, 81, 51), # rust
]
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
for path in (
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
):
try:
return ImageFont.truetype(path, size)
except OSError:
continue
return ImageFont.load_default()
def render_annotated_pdf(
drawing_path: Path,
results: list[dict],
out_path: Path,
filename: str = "drawing",
) -> Path:
"""Build a multi-page PDF: page 1 = legend, page 2 = annotated drawing.
results: [{id, description, count, matches: [{x,y,w,h,score,rot}, ...]}, ...]
"""
base = Image.open(drawing_path).convert("RGB")
annotated = base.copy()
draw = ImageDraw.Draw(annotated)
# Scale stroke width by image size so boxes are visible at any zoom
stroke = max(2, min(annotated.size) // 800)
label_font = _load_font(max(14, min(annotated.size) // 200))
for idx, r in enumerate(results):
color = PALETTE[idx % len(PALETTE)]
for m in r.get("matches", []):
x, y, w, h = m["x"], m["y"], m["w"], m["h"]
draw.rectangle([x, y, x + w, y + h], outline=color, width=stroke)
# Legend page
legend_w = annotated.size[0]
row_h = max(36, legend_w // 30)
legend_h = row_h * (len(results) + 4)
legend = Image.new("RGB", (legend_w, legend_h), "white")
ldraw = ImageDraw.Draw(legend)
title_font = _load_font(max(28, legend_w // 50))
ldraw.text((40, 30), f"Počítání symbolů — {filename}", font=title_font, fill="black")
ldraw.text(
(40, 30 + row_h),
f"Nalezeno {sum(r['count'] for r in results)} symbolů v {len(results)} kategoriích.",
font=label_font, fill="black",
)
y = 30 + 3 * row_h
swatch = row_h - 10
for idx, r in enumerate(results):
color = PALETTE[idx % len(PALETTE)]
ldraw.rectangle([40, y, 40 + swatch, y + swatch], fill=color, outline="black", width=2)
text = f"{r.get('description', r['id'])}{r['count']}×"
ldraw.text((40 + swatch + 16, y), text, font=label_font, fill="black")
y += row_h
# Save as multi-page PDF
legend.save(
out_path,
"PDF",
resolution=150.0,
save_all=True,
append_images=[annotated],
)
return out_path

351
dwg-counting/renderer.py Normal file
View File

@@ -0,0 +1,351 @@
"""Render DWG/DXF/PDF → PNG image(s) for vision model consumption.
Strategy: multi-floor architectural drawings are split per detected legend.
Each floor renders to its own PNG at a resolution where individual symbols
remain distinguishable for the vision model.
"""
import logging
import subprocess
from pathlib import Path
import io
import ezdxf
from ezdxf.addons.drawing.config import (
BackgroundPolicy, ColorPolicy, Configuration,
)
from ezdxf.addons.drawing import Frontend, RenderContext, layout
from ezdxf.addons.drawing.svg import SVGBackend
import cairosvg
from PIL import Image
logger = logging.getLogger(__name__)
RENDER_PX = 8000 # target pixel size of the longer edge — Claude vision max
def dwg_to_dxf(dwg_path: Path, out_dir: Path) -> 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=180,
)
if not dxf_path.exists():
raise RuntimeError(f"DWG→DXF failed (exit {r.returncode}): {r.stderr or r.stdout}")
return dxf_path
SKIP_LAYER_PATTERNS = (
"dimens", "kota", "kóta", "koty", # dimensions
"sit_konst", "konstrukce", "ckkoty", # structural / dimensioning
"ckprofi", "profily", "csprofily", # steel/concrete profile rezy
"sanita", "vzt", "ov_kan", "tzb", # plumbing / HVAC (when not the target)
"viewport", "defpoints", "0_", "_b_", # CAD bookkeeping
"raster", "wipeout",
)
def _clean_doc(doc, drop_layers=False):
"""Strip elements that overwhelm vision rendering."""
msp = doc.modelspace()
valid_blocks = {b.name for b in doc.blocks}
for e in list(msp.query("INSERT")):
if e.dxf.name not in valid_blocks:
msp.delete_entity(e)
for typ in ("HATCH", "SOLID", "MPOLYGON"):
for e in list(msp.query(typ)):
msp.delete_entity(e)
if drop_layers:
for e in list(msp):
layer = str(getattr(e.dxf, "layer", "")).lower()
if any(p in layer for p in SKIP_LAYER_PATTERNS):
try:
msp.delete_entity(e)
except Exception:
pass
for e in msp:
try:
e.dxf.lineweight = 5
except Exception:
pass
def find_floors(dxf_path: Path) -> list[dict]:
"""Find legend 'LEGENDA' markers — each represents one floor.
Returns list of {legend_xy, floor_bbox} dicts ordered top to bottom.
"""
doc = ezdxf.readfile(str(dxf_path))
msp = doc.modelspace()
positions = []
for e in msp:
text = ""
if e.dxftype() == "MTEXT":
text = e.text
elif e.dxftype() == "TEXT":
text = e.dxf.text
if text and text.strip().upper() == "LEGENDA":
try:
positions.append((e.dxf.insert.x, e.dxf.insert.y))
except Exception:
pass
positions.sort(key=lambda p: -p[1])
if not positions:
return [{"legend_xy": None, "floor_bbox": None}]
# Use the same cleanup as render_region so extents reflect what'll be drawn
_clean_doc(doc)
from ezdxf.bbox import extents
try:
ext = extents(msp, fast=True)
mxmin, mymin = ext.extmin.x, ext.extmin.y
mxmax, mymax = ext.extmax.x, ext.extmax.y
except Exception:
mxmin, mymin = -1e9, -1e9
mxmax, mymax = 1e9, 1e9
logger.info("find_floors: model extents x=(%.0f,%.0f) y=(%.0f,%.0f)",
mxmin, mxmax, mymin, mymax)
floors = []
for i, (lx, ly) in enumerate(positions):
if i + 1 < len(positions):
y_height = ly - positions[i + 1][1]
elif i > 0:
y_height = positions[i - 1][1] - ly
else:
y_height = 60000
# Legend appears at the TOP of its floor view; plan extends downward
y_top = min(mymax, ly + 0.10 * y_height)
y_bot = max(mymin, ly - 0.95 * y_height)
# X span = whole model width (floor plans typically span the page width)
x_left = mxmin
x_right = mxmax
floors.append({
"legend_xy": (lx, ly),
"floor_bbox": (x_left, y_bot, x_right, y_top),
})
return floors
def render_region(dxf_path: Path, out_path: Path, bbox: tuple | None) -> Path:
"""Render a DXF (optionally clipped to bbox) to PNG via SVG.
Pipeline: ezdxf → SVG (vector, faithful linework) → cairosvg → PNG.
Cropping is done in pixel space after rasterization, using the page
rectangle ezdxf assigned to the SVG.
"""
doc = ezdxf.readfile(str(dxf_path))
auditor = doc.audit()
if auditor.has_errors:
logger.info("DXF audit: %d errors", len(auditor.errors))
_clean_doc(doc)
msp = doc.modelspace()
from ezdxf.bbox import extents
try:
ext = extents(msp, fast=True)
model_xmin = ext.extmin.x
model_ymin = ext.extmin.y
model_w = ext.size.x or 1
model_h = ext.size.y or 1
except Exception:
model_xmin = model_ymin = 0
model_w = model_h = 1
config = Configuration(
background_policy=BackgroundPolicy.WHITE,
color_policy=ColorPolicy.BLACK,
lineweight_scaling=0.5,
min_lineweight=0.05,
)
# ezdxf SVG: target page sized so longest dimension is RENDER_PX pixels.
# SVG uses mm; pick a scale such that the page fits cleanly.
aspect = model_w / max(model_h, 1)
if aspect >= 1:
page_w_mm = 1000
page_h_mm = 1000 / aspect
else:
page_h_mm = 1000
page_w_mm = 1000 * aspect
page = layout.Page(width=page_w_mm, height=page_h_mm,
units=layout.Units.mm, margins=layout.Margins.all(0))
backend = SVGBackend()
Frontend(RenderContext(doc), backend, config=config).draw_layout(msp, finalize=True)
svg_str = backend.get_string(page)
# cairosvg renders SVG → PNG. output_width sets the PNG pixel width.
longest_px = RENDER_PX
out_width = longest_px if aspect >= 1 else int(longest_px * aspect)
png_bytes = cairosvg.svg2png(bytestring=svg_str.encode("utf-8"),
output_width=out_width)
img = Image.open(io.BytesIO(png_bytes))
# Ensure white background (cairosvg may produce alpha)
if img.mode == "RGBA":
white = Image.new("RGB", img.size, (255, 255, 255))
white.paste(img, mask=img.split()[3])
img = white
if bbox is not None:
W, H = img.size
xmin, ymin, xmax, ymax = bbox
logger.info("Crop input: model_w=%.0f model_h=%.0f W=%d H=%d bbox=%s",
model_w, model_h, W, H, bbox)
px0 = max(0, int((xmin - model_xmin) / model_w * W))
px1 = min(W, int((xmax - model_xmin) / model_w * W))
py0 = max(0, int((model_ymin + model_h - ymax) / model_h * H))
py1 = min(H, int((model_ymin + model_h - ymin) / model_h * H))
logger.info("Crop pixels: (%d,%d) → (%d,%d)", px0, py0, px1, py1)
if px1 > px0 and py1 > py0:
img = img.crop((px0, py0, px1, py1))
if max(img.size) > RENDER_PX:
r = RENDER_PX / max(img.size)
img = img.resize((int(img.size[0] * r), int(img.size[1] * r)), Image.LANCZOS)
img.save(out_path, "PNG", optimize=True)
logger.info("Rendered → %s (%dx%d)", out_path.name, img.size[0], img.size[1])
return out_path
def render(input_path: Path, out_dir: Path) -> dict:
"""Convert input → list of floor images.
Returns: {"floors": [{"index":0, "png":"floor_0.png", "legend_xy":[x,y]}, ...]}.
"""
suffix = input_path.suffix.lower()
if suffix == ".pdf":
return _render_pdf(input_path, out_dir)
if suffix == ".dwg":
dxf_path = dwg_to_dxf(input_path, out_dir)
elif suffix == ".dxf":
dxf_path = input_path
else:
raise ValueError(f"Unsupported format: {suffix}")
floors = find_floors(dxf_path)
logger.info("Detected %d floor(s) via LEGENDA markers", len(floors))
# MVP: render only the first floor. Multi-floor selection is a follow-up.
f = floors[0]
png = out_dir / "floor_0.png"
render_region(dxf_path, png, f["floor_bbox"])
return {"floors": [{"index": 0, "png": png.name,
"legend_xy": list(f["legend_xy"]) if f["legend_xy"] else None}]}
def _render_pdf(pdf_path: Path, out_dir: Path) -> dict:
"""Render PDF → PNG, auto-rotate so LEGENDA reads horizontally.
Uses pdfplumber to find 'LEGENDA' text and its rotation, then renders
via pdf2image and applies image rotation so the legend is upright.
Returns the same shape as the DXF render path.
"""
from pdf2image import convert_from_path
import pdfplumber
# Allow large rasterizations (architectural PDFs can be 200M+ px at high DPI)
Image.MAX_IMAGE_PIXELS = None
# Pick DPI so longest page edge lands near RENDER_PX pixels
with pdfplumber.open(str(pdf_path)) as pdf:
first = pdf.pages[0]
pw_in = max(first.width, first.height) / 72 # PDF points → inches
target_dpi = max(150, min(600, int(RENDER_PX / max(pw_in, 1))))
logger.info("PDF page longest edge %.1f in → using dpi=%d", pw_in, target_dpi)
pages = convert_from_path(str(pdf_path), dpi=target_dpi)
out_paths = []
legend_info: list[dict] = []
with pdfplumber.open(str(pdf_path)) as pdf:
for i, plumb_page in enumerate(pdf.pages):
page_img = pages[i] if i < len(pages) else None
if page_img is None:
continue
pw, ph = plumb_page.width, plumb_page.height
iw, ih = page_img.size
# Find any text matching legend headings
words = plumb_page.extract_words(extra_attrs=["upright"]) or []
legend_word = None
for w in words:
text = w["text"].strip().upper()
if text in ("LEGENDA", "VYSVĚTLIVKY", "LEGENDA:", "POPIS"):
legend_word = w
break
rotation = 0
if legend_word is not None and not legend_word.get("upright", True):
# Sideways text → rotate the image so text is upright.
# PIL.rotate uses CCW for positive angles.
rotation = 90
page_img = page_img.rotate(90, expand=True)
iw, ih = page_img.size
logger.info("PDF page %d: rotated 90° CCW (LEGENDA was sideways)", i)
if legend_word is not None:
# Convert PDF coords to NORMALIZED image coords (after rotation)
x0, y0 = legend_word["x0"], legend_word["top"]
x1, y1 = legend_word["x1"], legend_word["bottom"]
if rotation == 90:
# CCW 90° rotation: original (x, y) → new (y, W-x).
# Rotated image has width=ph, height=pw.
nx0 = y0 / ph
nx1 = y1 / ph
ny0 = 1 - (x1 / pw)
ny1 = 1 - (x0 / pw)
else:
nx0, nx1 = x0 / pw, x1 / pw
ny0, ny1 = y0 / ph, y1 / ph
legend_info.append({"page": i, "norm_bbox": (nx0, ny0, nx1, ny1)})
logger.info("PDF page %d: LEGENDA at norm bbox %s",
i, (nx0, ny0, nx1, ny1))
if max(page_img.size) > RENDER_PX:
r = RENDER_PX / max(page_img.size)
page_img = page_img.resize(
(int(page_img.size[0] * r), int(page_img.size[1] * r)),
Image.LANCZOS,
)
p = out_dir / f"floor_{i}.png"
page_img.save(p, "PNG", optimize=True)
out_paths.append(p)
return {
"floors": [
{"index": i, "png": p.name, "legend_xy": None,
"legend_norm_bbox": next((li["norm_bbox"] for li in legend_info
if li["page"] == i), None)}
for i, p in enumerate(out_paths)
]
}
def crop_region(image_path: Path, bbox: dict, out_path: Path,
pad: float = 1.5, min_px: int = 120) -> Path:
"""Crop a region with generous padding so symbols are visible.
pad: multiplier of the bbox half-extent added on each side.
min_px: ensure the output is at least this many pixels wide/tall by
expanding the crop region if the requested area is smaller.
"""
img = Image.open(image_path)
W, H = img.size
bx, by, bw, bh = bbox["x"], bbox["y"], bbox["w"], bbox["h"]
cx, cy = bx + bw / 2, by + bh / 2
# Padded extents in normalized coords
half_w = bw / 2 + bw * pad
half_h = bh / 2 + bh * pad
# Enforce minimum pixel dimensions
if (2 * half_w) * W < min_px:
half_w = (min_px / 2) / W
if (2 * half_h) * H < min_px:
half_h = (min_px / 2) / H
x0 = max(0, int((cx - half_w) * W))
x1 = min(W, int((cx + half_w) * W))
y0 = max(0, int((cy - half_h) * H))
y1 = min(H, int((cy + half_h) * H))
crop = img.crop((x0, y0, x1, y1))
crop.save(out_path, "PNG")
return out_path

View File

@@ -0,0 +1,16 @@
fastapi>=0.115
uvicorn[standard]>=0.30
ezdxf>=1.3
matplotlib>=3.8
cairosvg>=2.7
Pillow>=10.0
pdf2image>=1.17
pdfplumber>=0.11
opencv-python-headless>=4.10
numpy>=1.26
reportlab>=4.0
openpyxl>=3.1
python-multipart>=0.0.9
openai>=1.50
python-dotenv>=1.0
aiofiles>=23.0

442
dwg-counting/static/app.js Normal file
View File

@@ -0,0 +1,442 @@
// dwg-counting frontend
(() => {
const $ = (id) => document.getElementById(id);
const sections = {
upload: $("s-upload"),
processing: $("s-processing"),
symbols: $("s-symbols"),
results: $("s-results"),
};
const show = (name) => {
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name);
};
let state = { jobId: null, symbols: [], selected: new Set(), results: [] };
// Upload handlers
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");
$("processing-title").textContent = "Připravuji výkres…";
const fd = new FormData();
fd.append("file", file);
try {
// Skip auto-detection by default — user defines symbols manually
const r = await fetch("/api/upload?auto_detect=false", {
method: "POST", body: fd,
});
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
const data = await r.json();
state.jobId = data.job_id;
state.symbols = data.symbols || [];
state.selected = new Set();
renderSymbols();
show("symbols");
} catch (err) {
alert("Chyba: " + err.message);
show("upload");
}
}
$("auto-detect-btn").addEventListener("click", async () => {
if (!state.jobId) return;
show("processing");
$("processing-title").textContent = "Hledám legendu pomocí AI…";
try {
const r = await fetch(`/api/auto-detect/${state.jobId}`, { method: "POST" });
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
const data = await r.json();
state.symbols = data.symbols || [];
renderSymbols();
show("symbols");
} catch (err) {
alert("Chyba: " + err.message);
show("symbols");
}
});
$("add-symbol-btn").addEventListener("click", () => {
openAddSymbolModal();
});
$("upload-symbol-btn").addEventListener("click", () => {
$("symbol-file-input").click();
});
$("symbol-file-input").addEventListener("change", async (e) => {
const f = e.target.files[0];
if (!f) return;
const name = prompt("Název symbolu:", f.name.replace(/\.[a-z]+$/i, ""));
if (!name) return;
const fd = new FormData();
fd.append("file", f);
fd.append("description", name);
try {
const r = await fetch(`/api/symbols/${state.jobId}/upload`, { method: "POST", body: fd });
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
const sym = await r.json();
state.symbols.push(sym);
renderSymbols();
} catch (err) { alert("Chyba: " + err.message); }
e.target.value = "";
});
function renderSymbols() {
const meta = $("symbols-meta");
if (!state.symbols.length) {
meta.textContent = 'Klikněte „+ Přidat symbol" a vyznačte ve výkresu, co chcete počítat. Nebo zkuste „Auto-detekce z legendy" pro automatický návrh.';
$("symbols-grid").innerHTML = "";
return;
}
meta.textContent = `${state.symbols.length} symbolů. Zaškrtněte k spočítání, ✎ upravit, 🗑 smazat.`;
const grid = $("symbols-grid");
grid.innerHTML = "";
for (const s of state.symbols) {
const card = document.createElement("div");
card.className = "symbol-card";
card.innerHTML = `
<input type="checkbox" data-id="${s.id}">
<img class="symbol-thumb" src="/api/symbol/${state.jobId}/${s.id}?v=${s._v||0}" alt=""
onerror="this.style.visibility='hidden'">
<div class="symbol-info">
<div class="symbol-id">${s.id}</div>
<div class="symbol-desc">${escapeHtml(s.description || "")}</div>
</div>
<button class="symbol-edit" type="button" title="Upravit výřez">✎</button>
<button class="symbol-debug" type="button" title="Debug: zobrazit šablonu a skóre">🔍</button>
<button class="symbol-delete" type="button" title="Smazat symbol">🗑</button>`;
const cb = card.querySelector("input");
cb.addEventListener("change", () => {
if (cb.checked) { state.selected.add(s.id); card.classList.add("selected"); }
else { state.selected.delete(s.id); card.classList.remove("selected"); }
$("count-btn").disabled = state.selected.size === 0;
});
// Click on row toggles checkbox (except on edit btn / thumb)
card.addEventListener("click", (e) => {
if (e.target.closest(".symbol-edit") || e.target === cb) return;
cb.checked = !cb.checked;
cb.dispatchEvent(new Event("change"));
});
card.querySelector(".symbol-edit").addEventListener("click", (e) => {
e.stopPropagation();
openCropModal(s);
});
card.querySelector(".symbol-debug").addEventListener("click", (e) => {
e.stopPropagation();
openDebugModal(s);
});
card.querySelector(".symbol-delete").addEventListener("click", async (e) => {
e.stopPropagation();
if (!confirm(`Smazat symbol "${s.description || s.id}"?`)) return;
try {
const r = await fetch(`/api/symbols/${state.jobId}/${s.id}`, { method: "DELETE" });
if (!r.ok) throw new Error("delete failed");
state.symbols = state.symbols.filter(x => x.id !== s.id);
state.selected.delete(s.id);
renderSymbols();
$("count-btn").disabled = state.selected.size === 0;
} catch (err) { alert("Chyba: " + err.message); }
});
grid.appendChild(card);
}
}
// Add-new-symbol modal: same crop modal, but uses full drawing image and
// asks for a name.
function openAddSymbolModal() {
const modal = $("crop-modal");
const img = $("crop-img");
const sel = $("crop-selection");
const wrap = $("crop-canvas-wrap");
$("crop-modal-title").textContent = "Nový symbol — vyznačte oblast ve výkresu";
$("crop-modal-hint").textContent =
"DOPORUČENO: najděte symbol PŘÍMO ve výkresu (ne v legendě) — barvy a tloušťka čar tam přesně odpovídají tomu, co budeme hledat. Vyznačte těsný rámeček kolem jedné instance symbolu a pojmenujte ho.";
$("crop-name-row").classList.remove("hidden");
$("crop-name").value = "";
img.src = `/api/drawing/${state.jobId}`;
sel.style.display = "none";
$("crop-save").disabled = true;
cropState = { mode: "create", sym: null, bbox: null, dragging: false };
modal.classList.remove("hidden");
attachCropHandlers(wrap, img, sel);
}
// ── Crop modal logic ──────────────────────
let cropState = null;
function openCropModal(sym) {
const modal = $("crop-modal");
const img = $("crop-img");
const sel = $("crop-selection");
const wrap = $("crop-canvas-wrap");
$("crop-modal-title").textContent = `Upravit symbol: ${sym.description || sym.id}`;
$("crop-modal-hint").textContent =
"Označte oblast obsahující pouze grafický symbol (bez popisu).";
$("crop-name-row").classList.add("hidden");
// For editing: use the full drawing so user can pick any instance
img.src = `/api/drawing/${state.jobId}`;
sel.style.display = "none";
$("crop-save").disabled = true;
cropState = { mode: "edit", sym, bbox: null, dragging: false };
modal.classList.remove("hidden");
attachCropHandlers(wrap, img, sel);
return;
}
function attachCropHandlers(wrap, img, sel) {
// Mouse coords relative to the IMAGE's top-left (in image-pixel space,
// which is also wrap's content space since image is at native scale and
// sits at content origin (0,0) in the relative-positioned wrap).
function localXY(e) {
const rect = img.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
function onMouseDown(e) {
const p = localXY(e);
cropState.startX = p.x;
cropState.startY = p.y;
cropState.dragging = true;
sel.style.display = "block";
sel.style.left = p.x + "px";
sel.style.top = p.y + "px";
sel.style.width = "0px";
sel.style.height = "0px";
}
function onMouseMove(e) {
if (!cropState || !cropState.dragging) return;
const p = localXY(e);
const left = Math.min(p.x, cropState.startX);
const top = Math.min(p.y, cropState.startY);
const w = Math.abs(p.x - cropState.startX);
const h = Math.abs(p.y - cropState.startY);
sel.style.left = left + "px";
sel.style.top = top + "px";
sel.style.width = w + "px";
sel.style.height = h + "px";
}
function onMouseUp() {
if (!cropState) return;
cropState.dragging = false;
const rect = img.getBoundingClientRect();
const dispW = rect.width, dispH = rect.height;
const left = parseFloat(sel.style.left), top = parseFloat(sel.style.top);
const w = parseFloat(sel.style.width), h = parseFloat(sel.style.height);
if (w < 5 || h < 5) {
sel.style.display = "none";
$("crop-save").disabled = true;
return;
}
cropState.bbox = {
x: left / dispW,
y: top / dispH,
w: w / dispW,
h: h / dispH,
};
$("crop-save").disabled = false;
}
wrap.onmousedown = onMouseDown;
wrap.onmousemove = onMouseMove;
wrap.onmouseup = onMouseUp;
wrap.onmouseleave = onMouseUp;
}
function closeCropModal() {
$("crop-modal").classList.add("hidden");
cropState = null;
}
async function openDebugModal(sym) {
const modal = $("debug-modal");
const body = $("debug-body");
$("debug-title").textContent = `Debug: ${sym.description || sym.id}`;
body.innerHTML = '<p style="padding:20px">Načítám diagnostiku…</p>';
modal.classList.remove("hidden");
try {
const r = await fetch(`/api/debug/${state.jobId}/${sym.id}`);
if (!r.ok) throw new Error(await r.text());
const info = await r.json();
const tmplURL = `/api/symbol/${state.jobId}/${sym.id}?v=${sym._v||0}`;
const procURL = `/api/debug-template/${state.jobId}/${sym.id}?v=${sym._v||0}&t=${Date.now()}`;
const drawURL = `/api/drawing/${state.jobId}`;
const matches = info.matches_at_threshold || {};
const maxMatchCount = Math.max(1, ...Object.values(matches));
const threshRows = Object.entries(matches).map(([t, n]) => `
<div>práh ${t}</div>
<div class="debug-bar"><div class="debug-bar-fill" style="width:${Math.min(100,(n/maxMatchCount)*100)}%"></div></div>
<div>${n} shod</div>`).join("");
const inkPct = info.template_total_pixels
? ((info.template_ink_pixels / info.template_total_pixels) * 100).toFixed(1)
: "?";
body.innerHTML = `
<div class="debug-section">
<h4>Obrazy</h4>
<div class="debug-thumbs">
<div class="debug-thumb">
<a href="${tmplURL}" target="_blank"><img src="${tmplURL}" alt=""></a>
<span>Šablona (uložená)</span>
</div>
<div class="debug-thumb">
<a href="${procURL}" target="_blank"><img src="${procURL}" alt=""></a>
<span>Po předzpracování<br>(co matcher vidí)</span>
</div>
<div class="debug-thumb">
<a href="${drawURL}" target="_blank">otevřít celý výkres ↗</a>
<span>${info.drawing_size.join(" × ")} px</span>
</div>
</div>
</div>
<div class="debug-section">
<h4>Měření</h4>
<div class="debug-kv">
<strong>Šablona originál</strong><span>${info.template_size.join(" × ")} px</span>
<strong>Šablona po ořezu</strong><span>${info.template_cropped_size.join(" × ")} px</span>
<strong>Inkoust v šabloně</strong><span>${info.template_ink_pixels} / ${info.template_total_pixels} px (${inkPct}%)</span>
<strong>Výkres</strong><span>${info.drawing_size.join(" × ")} px</span>
<strong>Nejlepší skóre</strong><span>${info.max_score?.toFixed(3) ?? "—"}</span>
</div>
</div>
<div class="debug-section">
<h4>Shody při různých prazích (bez rotací, scale 1.0)</h4>
<div class="debug-thresholds">${threshRows}</div>
<p style="margin-top:8px; color: var(--text-tertiary); font-size:12px">
Aktuálně používaný práh pro počítání: <strong>0.75</strong>.
Skutečné počty (s rotacemi 0/90/180/270° a třemi scale faktory) jsou typicky nižší kvůli dedupli­kaci.
</p>
</div>
<div class="debug-section" id="debug-interpret"></div>
`;
// Heuristic interpretation
const interpret = [];
if (info.template_ink_pixels < 30)
interpret.push('⚠ Šablona obsahuje příliš málo „inkoustu" — pravděpodobně jste vybrali převážně bílou plochu. Vyznačte rámeček těsně kolem čar symbolu.');
if (Math.min(...info.template_cropped_size) < 12)
interpret.push("⚠ Šablona je velmi malá (<12 px). Malé šablony hlásí spoustu falešných shod nebo žádné. Zkuste přidat trochu okolí.");
if ((info.max_score ?? 0) < 0.5)
interpret.push("⚠ Nejlepší skóre v celém výkresu je velmi nízké — symbol vypadá v plánu jinak než v šabloně. Zkuste vyznačit symbol přímo z plánu (ne z legendy), nebo zkuste jinou variantu/orientaci.");
else if ((info.max_score ?? 0) < 0.7)
interpret.push(" Nejlepší skóre ≈ " + info.max_score.toFixed(2) + ". Práh 0.75 je nad maximem — zkuste snížit práh nebo upravit šablonu těsněji.");
else
interpret.push("✓ Symbol se v plánu vyskytuje. Pokud nejsou shody, může jít o problém s rotacemi nebo měřítkem.");
$("debug-interpret").innerHTML = "<h4>Interpretace</h4>" +
interpret.map(t => `<p>${t}</p>`).join("");
} catch (err) {
body.innerHTML = `<p style="color:#dc2626; padding:12px">Chyba: ${err.message}</p>`;
}
}
$("debug-close").addEventListener("click", () => $("debug-modal").classList.add("hidden"));
$("debug-ok").addEventListener("click", () => $("debug-modal").classList.add("hidden"));
$("crop-close").addEventListener("click", closeCropModal);
$("crop-cancel").addEventListener("click", closeCropModal);
$("crop-save").addEventListener("click", async () => {
if (!cropState || !cropState.bbox) return;
try {
if (cropState.mode === "create") {
const name = ($("crop-name").value || "").trim();
if (!name) { alert("Zadejte název symbolu."); return; }
const r = await fetch(`/api/symbols/${state.jobId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bbox: cropState.bbox,
description: name,
source: "drawing",
}),
});
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
const newSym = await r.json();
state.symbols.push(newSym);
} else {
const r = await fetch(`/api/symbol/${state.jobId}/${cropState.sym.id}/recrop`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bbox: cropState.bbox }),
});
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
cropState.sym._v = (cropState.sym._v || 0) + 1;
}
renderSymbols();
closeCropModal();
} catch (err) {
alert("Chyba: " + err.message);
}
});
// Threshold slider live readout
const thrSlider = $("threshold-slider");
const thrValue = $("threshold-value");
thrSlider.addEventListener("input", () => {
thrValue.textContent = parseFloat(thrSlider.value).toFixed(2);
});
$("count-btn").addEventListener("click", async () => {
show("processing");
$("processing-title").textContent = `Počítám ${state.selected.size} symbolů…`;
try {
const r = await fetch(`/api/count/${state.jobId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
symbol_ids: [...state.selected],
threshold: parseFloat(thrSlider.value),
}),
});
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
const data = await r.json();
state.results = data.results || [];
renderResults();
show("results");
} catch (err) {
alert("Chyba: " + err.message);
show("symbols");
}
});
function renderResults() {
const total = state.results.reduce((a, r) => a + (r.count || 0), 0);
$("results-meta").textContent = `Celkem ${total} symbolů ve ${state.results.length} kategoriích.`;
const tb = $("results-tbody");
tb.innerHTML = "";
for (const r of state.results) {
const tr = document.createElement("tr");
const conf = r.confidence || "";
tr.innerHTML = `
<td><img class="symbol-thumb" style="width:40px;height:40px" src="/api/symbol/${state.jobId}/${r.id}" alt=""></td>
<td>${escapeHtml(r.description || "")}</td>
<td><strong>${r.count}</strong></td>
<td class="confidence-${conf}">${conf || "—"}</td>
<td>${escapeHtml(r.notes || "")}</td>`;
tb.appendChild(tr);
}
}
$("export-btn").addEventListener("click", () => {
window.location.href = `/api/export/${state.jobId}`;
});
$("pdf-btn").addEventListener("click", () => {
window.location.href = `/api/export-pdf/${state.jobId}`;
});
$("back-btn").addEventListener("click", () => show("symbols"));
$("reset-btn").addEventListener("click", () => {
state = { jobId: null, symbols: [], selected: new Set(), results: [] };
fileInput.value = "";
show("upload");
});
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
})();

View File

@@ -0,0 +1,301 @@
/* Extra styles specific to dwg-counting */
.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);
transition: color 0.15s, border-color 0.15s, background 0.15s;
flex-shrink: 0;
}
.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; }
.back-link svg { width: 16px; height: 16px; }
}
.processing-sub {
font-size: 13px;
color: var(--text-tertiary);
margin-top: 8px;
}
.symbols-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
margin-top: 16px;
}
.symbol-card {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 12px;
display: flex;
gap: 12px;
align-items: center;
cursor: pointer;
transition: border-color .15s, box-shadow .15s;
}
.symbol-card:hover {
border-color: var(--primary);
}
.symbol-card.selected {
border-color: var(--primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 25%, transparent);
}
.symbol-card input[type=checkbox] {
width: 18px;
height: 18px;
flex-shrink: 0;
cursor: pointer;
accent-color: var(--primary);
}
.symbol-thumb {
width: 64px;
height: 64px;
background: #fff;
border: 1px solid var(--border-default);
border-radius: 6px;
object-fit: contain;
padding: 4px;
flex-shrink: 0;
}
.symbol-info {
flex: 1;
min-width: 0;
}
.symbol-id {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
font-weight: 600;
}
.symbol-desc {
font-size: 13px;
color: var(--text-primary);
margin-top: 2px;
line-height: 1.35;
}
.threshold-row {
display: flex;
align-items: center;
gap: 12px;
margin: 12px 0;
padding: 10px 14px;
background: var(--bg-tertiary);
border-radius: 8px;
font-size: 13px;
flex-wrap: wrap;
}
.threshold-row label { font-weight: 600; }
.threshold-row input[type=range] {
flex: 0 0 240px;
accent-color: var(--primary);
}
#threshold-value {
display: inline-block;
min-width: 40px;
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--primary);
}
.threshold-hint { color: var(--text-tertiary); font-size: 12px; flex: 1; }
.confidence-high { color: #16a34a; font-weight: 600; }
.confidence-medium { color: #d97706; font-weight: 600; }
.confidence-low { color: #dc2626; font-weight: 600; }
/* Edit button on symbol cards */
.symbol-edit {
background: transparent;
border: 1px solid var(--border-default);
border-radius: 6px;
padding: 4px 8px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
flex-shrink: 0;
}
.symbol-edit:hover {
border-color: var(--primary);
color: var(--primary);
}
/* Crop modal */
.modal {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: var(--card);
border-radius: var(--radius-lg);
padding: 20px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.btn-close {
background: transparent;
border: none;
font-size: 28px;
line-height: 1;
cursor: pointer;
color: var(--text-tertiary);
}
.modal-hint {
font-size: 13px;
color: var(--text-tertiary);
margin-bottom: 12px;
}
.crop-canvas-wrap {
position: relative;
overflow: auto;
max-height: 65vh;
border: 1px solid var(--border-default);
background: #fff;
cursor: crosshair;
}
#crop-img {
display: block;
max-width: none;
user-select: none;
pointer-events: none;
}
#crop-selection {
position: absolute;
border: 2px dashed var(--primary);
background: rgba(21, 90, 239, 0.08);
pointer-events: none;
display: none;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.crop-name-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.crop-name-row label {
font-size: 13px;
font-weight: 600;
}
.crop-name-row input {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--border-default);
border-radius: 6px;
font-size: 14px;
}
.symbol-delete {
background: transparent;
border: 1px solid transparent;
color: var(--text-tertiary);
font-size: 14px;
padding: 4px 8px;
cursor: pointer;
border-radius: 6px;
flex-shrink: 0;
}
.symbol-delete:hover {
color: #dc2626;
border-color: #dc2626;
}
.symbol-debug {
background: transparent;
border: 1px solid transparent;
color: var(--text-tertiary);
font-size: 14px;
padding: 4px 8px;
cursor: pointer;
border-radius: 6px;
flex-shrink: 0;
}
.symbol-debug:hover {
color: var(--primary);
border-color: var(--primary);
}
.debug-body {
font-size: 13px;
line-height: 1.5;
color: var(--text-primary);
max-height: 70vh;
overflow-y: auto;
}
.debug-section {
margin-bottom: 14px;
padding-bottom: 14px;
border-bottom: 1px solid var(--border-default);
}
.debug-section:last-child { border-bottom: none; }
.debug-section h4 {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
margin-bottom: 8px;
}
.debug-kv { display: grid; grid-template-columns: 220px 1fr; gap: 4px 12px; }
.debug-kv strong { color: var(--text-secondary); font-weight: 500; }
.debug-thumbs { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
.debug-thumb {
display: flex; flex-direction: column; align-items: center;
gap: 4px; min-width: 120px;
}
.debug-thumb a {
display: block;
border: 1px solid var(--border-default);
padding: 4px;
background: #fff;
cursor: zoom-in;
}
.debug-thumb img { display: block; max-width: 160px; max-height: 160px; }
.debug-thumb span { font-size: 11px; color: var(--text-tertiary); }
.debug-thresholds { display: grid; grid-template-columns: 60px 1fr 60px; gap: 4px 10px; align-items: center; }
.debug-bar {
height: 8px;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
}
.debug-bar-fill {
height: 100%;
background: var(--primary);
}

View File

@@ -0,0 +1,205 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Počítání symbolů z PDF výkresu | 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>
// Theme handling — runs before first paint to prevent flash.
// Priority: URL ?theme= → localStorage → portal_theme cookie → system.
(function () {
var t = null;
try {
var url = new URL(window.location.href);
var p = url.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">Počítání symbolů z PDF výkresu</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">Počítání symbolů z PDF výkresu</h1>
<p class="section-desc">
Nahrajte výkres ve formátu <strong>PDF</strong>, vyznačte symboly, které
chcete spočítat, a aplikace najde jejich výskyty v celém výkresu.
Výsledek lze stáhnout jako Excel nebo jako PDF s vyznačením.
</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 PDF výkres 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>
</section>
<!-- ── Processing ────────────────────────── -->
<section id="s-processing" class="hidden">
<div class="processing-card">
<div class="spinner"></div>
<h2 class="processing-title" id="processing-title">Hledám legendu ve výkresu…</h2>
<p class="processing-sub" id="processing-sub">Toto může trvat 2060 sekund.</p>
</div>
</section>
<!-- ── Symbol selection ──────────────────── -->
<section id="s-symbols" class="hidden">
<div class="results-header">
<div>
<h2 class="results-title">Vyberte symboly k spočítání</h2>
<p class="results-meta" id="symbols-meta"></p>
</div>
<div class="results-actions">
<button class="btn btn-secondary" id="reset-btn" type="button">Nahrát jiný</button>
<button class="btn btn-secondary" id="auto-detect-btn" type="button" title="Najít legendu pomocí AI">
Auto-detekce z legendy
</button>
<button class="btn btn-primary" id="add-symbol-btn" type="button">
+ Vyříznout symbol z výkresu
</button>
<button class="btn btn-secondary" id="upload-symbol-btn" type="button">
+ Nahrát PNG
</button>
<input type="file" id="symbol-file-input" accept="image/*" style="display:none">
<button class="btn btn-primary" id="count-btn" type="button" disabled>
Spočítat vybrané
</button>
</div>
</div>
<div class="threshold-row">
<label for="threshold-slider">Citlivost (práh shody):</label>
<input type="range" id="threshold-slider" min="0.40" max="0.95" step="0.01" value="0.70">
<span id="threshold-value">0.70</span>
<span class="threshold-hint">nižší = víc nálezů (i falešných), vyšší = jen přesné shody</span>
</div>
<div class="symbols-grid" id="symbols-grid"></div>
</section>
<!-- ── Debug modal ───────────────────────── -->
<div id="debug-modal" class="modal hidden">
<div class="modal-content" style="max-width:760px">
<div class="modal-header">
<h3 id="debug-title">Debug symbolu</h3>
<button class="btn-close" id="debug-close" type="button">×</button>
</div>
<div id="debug-body" class="debug-body"></div>
<div class="modal-actions">
<button class="btn btn-primary" id="debug-ok" type="button">Zavřít</button>
</div>
</div>
</div>
<!-- ── Crop editor modal ─────────────────── -->
<div id="crop-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3 id="crop-modal-title">Vyznačte symbol</h3>
<button class="btn-close" id="crop-close" type="button">×</button>
</div>
<p class="modal-hint" id="crop-modal-hint">Kliknutím a tažením vyberte oblast obsahující grafický symbol.</p>
<div class="crop-name-row hidden" id="crop-name-row">
<label for="crop-name">Název symbolu:</label>
<input type="text" id="crop-name" placeholder="např. Zásuvka 230V">
</div>
<div class="crop-canvas-wrap" id="crop-canvas-wrap">
<img id="crop-img" alt="" draggable="false">
<div id="crop-selection"></div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" id="crop-cancel" type="button">Zrušit</button>
<button class="btn btn-primary" id="crop-save" type="button" disabled>Uložit výřez</button>
</div>
</div>
</div>
<!-- ── Results ───────────────────────────── -->
<section id="s-results" class="hidden">
<div class="results-header">
<div>
<h2 class="results-title">Výsledky počítání</h2>
<p class="results-meta" id="results-meta"></p>
</div>
<div class="results-actions">
<button class="btn btn-secondary" id="back-btn" type="button">Zpět na výběr</button>
<button class="btn btn-secondary" id="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>
PDF s vyznačením
</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:80px">Symbol</th>
<th>Popis</th>
<th style="width:80px">Počet</th>
<th style="width:120px">Spolehlivost</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody id="results-tbody"></tbody>
</table>
</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; }

121
dwg-counting/vision.py Normal file
View File

@@ -0,0 +1,121 @@
"""Claude Sonnet 4 vision pipeline: legend detection + symbol counting."""
import base64
import json
import logging
import os
from pathlib import Path
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://litellm-proxy:4000/v1"),
api_key=os.getenv("LITELLM_API_KEY", "sk-dummy"),
)
return _client
def _b64(path: Path) -> str:
return base64.b64encode(path.read_bytes()).decode("ascii")
MODEL = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-20250514")
LEGEND_PROMPT = """You are analyzing a Czech architectural / engineering drawing (HVAC, electrical, plumbing, fire safety, etc).
NOTE: The drawing may be rotated 90° or 180°. Mentally rotate the page until text reads horizontally before searching. The legend is typically near a page edge or corner, often in a colored (yellow / red / green) text block that lists symbols and their meanings.
Your task: find any legend / symbol key in this drawing. Possible Czech headings include "LEGENDA", "VYSVĚTLIVKY", "POPIS", "POPIS SYMBOLŮ", "POUŽITÉ ZNAČENÍ". A legend is a column-table where each row pairs a small graphical symbol with a Czech description.
For each symbol entry found, return:
- `id`: short stable identifier (1-3 words, lowercase with underscores; e.g. "smoke_detector", "socket_230v", "valve_3way")
- `description`: full Czech description text exactly as written
- `bbox`: bounding box of THE SYMBOL ONLY (NOT the description text), normalized 0-1 coords relative to the full image. Format `{"x": 0.05, "y": 0.10, "w": 0.02, "h": 0.02}` where x,y is the top-left corner.
Return ONLY valid JSON, no markdown, no commentary:
{"symbols": [{"id":"...","description":"...","bbox":{"x":0,"y":0,"w":0,"h":0}}]}
If you genuinely cannot find any legend, return {"symbols": [], "reason": "<one-line Czech explanation of where you looked>"}.
Skip rows that are room schedules, material totals, or project info — only include rows showing actual graphical symbols with descriptions."""
COUNT_PROMPT_TEMPLATE = """You are counting graphical symbols in a Czech architectural/engineering drawing.
I will show you:
1. A reference symbol crop from the drawing's legend
2. The full drawing image
Your task: count the number of times the reference symbol appears in the full drawing. Look only at the drawing area (not the legend itself). The symbol may be rotated 0°, 90°, 180°, 270° — count rotated instances. Ignore size variations within reason (the symbol scale should be similar).
Reference symbol description (from legend): "{description}"
Return ONLY valid JSON, no markdown:
{{"count": <integer>, "confidence": <"low"|"medium"|"high">, "notes": "<optional brief note about uncertainty>"}}"""
async def detect_legend(image_path: Path) -> list[dict]:
"""Pass 1: Find legend, return list of symbols with bbox."""
img_b64 = _b64(image_path)
resp = await _get_client().chat.completions.create(
model=MODEL,
messages=[{
"role": "user",
"content": [
{"type": "text", "text": LEGEND_PROMPT},
{"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{img_b64}"}},
],
}],
max_tokens=4000,
temperature=0.0,
)
raw = (resp.choices[0].message.content or "").strip()
logger.info("Legend raw response (first 800 chars): %s", raw[:800])
raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
logger.error("Legend JSON parse failed: %s\nraw=%s", e, raw[:500])
return []
symbols = data.get("symbols", []) if isinstance(data, dict) else []
logger.info("Legend detection found %d symbols", len(symbols))
return symbols
async def count_symbol(symbol_crop: Path, full_image: Path, description: str) -> dict:
"""Pass 2: Count instances of one symbol in the drawing."""
crop_b64 = _b64(symbol_crop)
full_b64 = _b64(full_image)
prompt = COUNT_PROMPT_TEMPLATE.format(description=description)
resp = await _get_client().chat.completions.create(
model=MODEL,
messages=[{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "text", "text": "Reference symbol:"},
{"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{crop_b64}"}},
{"type": "text", "text": "Full drawing:"},
{"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{full_b64}"}},
],
}],
max_tokens=300,
temperature=0.0,
)
raw = (resp.choices[0].message.content or "").strip()
raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
try:
return json.loads(raw)
except json.JSONDecodeError:
logger.error("Count JSON parse failed: %s", raw[:200])
return {"count": 0, "confidence": "low", "notes": "parse failed"}

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

View File

@@ -0,0 +1,3 @@
LITELLM_BASE_URL=http://host.docker.internal:4000/v1
LITELLM_API_KEY=sk-...
LLM_MODEL=anthropic/claude-sonnet-4-20250514

12
email-drafter/Dockerfile Normal file
View File

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

View File

@@ -0,0 +1,19 @@
services:
email-drafter:
build: .
container_name: email-drafter
restart: unless-stopped
ports:
- "127.0.0.1:3028: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
networks:
localai:
external: true

150
email-drafter/main.py Normal file
View File

@@ -0,0 +1,150 @@
"""FastAPI: bullet notes → polished business email via Claude Sonnet 4."""
import json
import logging
import os
from typing import Literal
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from openai import AsyncOpenAI
from pydantic import BaseModel, Field
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="Email Drafter")
app.add_middleware(CORSMiddleware, allow_origins=["*"],
allow_methods=["*"], allow_headers=["*"])
MODEL = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-20250514")
_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
class GenerateRequest(BaseModel):
notes: str = Field(..., min_length=1, max_length=4000)
recipient: str = ""
signature: str = ""
tone: Literal["formal", "friendly", "firm", "apologetic"] = "formal"
language: Literal["cs", "en"] = "cs"
reply_to: str = "" # Optional original email being replied to
TONE_HINTS = {
"formal": {"cs": "formální, oficiální, profesionální",
"en": "formal, professional, polite"},
"friendly": {"cs": "přátelský, vstřícný, kolegiální",
"en": "friendly, warm, collegial"},
"firm": {"cs": "důrazný, asertivní, věcný, ale zdvořilý",
"en": "firm, assertive, direct yet polite"},
"apologetic": {"cs": "omluvný, smířlivý, akceptující odpovědnost",
"en": "apologetic, conciliatory, owning responsibility"},
}
def _system_prompt(language: str, tone: str) -> str:
tone_hint = TONE_HINTS[tone][language]
if language == "cs":
return f"""Jste asistent píšící obchodní e-maily v češtině.
Vstupem jsou poznámky / odrážky od pisatele. Z nich vytvoříte uhlazený, profesionální e-mail.
Tón: {tone_hint}.
Vraťte POUZE platný JSON v tomto tvaru, žádné markdown obaly:
{{
"subject": "Předmět e-mailu (krátký, výstižný, do 70 znaků)",
"body": "Tělo e-mailu včetně oslovení na začátku a podpisu na konci. Použijte odstavce oddělené prázdným řádkem."
}}
Pravidla:
- Pište v 1. osobě jednotného čísla (jako pisatel sám)
- Oslovení voľte podle kontextu příjemce (Vážený pane / Ahoj / atd.)
- Pokud vstup obsahuje žádost, zformulujte ji jasně a zdvořile
- Nevymýšlejte si fakta, která nejsou ve vstupu
- Pokud je dodán podpis, použijte ho doslovně. Jinak ukončete obecným podpisem typu „S pozdravem,\\nVaše jméno"
- Pokud je dodán původní e-mail (reply_to), navažte na něj — odkažte na to, co píše"""
else:
return f"""You are an assistant writing business emails in English.
The input is bullet notes from the writer. Turn them into a polished, professional email.
Tone: {tone_hint}.
Return ONLY valid JSON in this shape, no markdown wrappers:
{{
"subject": "Email subject (short, to the point, under 70 chars)",
"body": "Email body including greeting at top and signature at end. Use paragraphs separated by blank lines."
}}
Rules:
- Write in first person (as the sender)
- Choose greeting based on recipient context (Dear / Hi / Hello)
- If input contains a request, phrase it clearly and politely
- Do not invent facts not in the input
- If a signature is provided, use it verbatim. Otherwise end with a generic "Best regards,\\nYour name"
- If an original email is provided (reply_to), reference it appropriately"""
@app.get("/")
async def root():
return FileResponse("static/index.html")
@app.post("/api/generate")
async def generate(req: GenerateRequest):
user_parts = [f"POZNÁMKY PISATELE / WRITER NOTES:\n{req.notes.strip()}"]
if req.recipient.strip():
user_parts.append(f"\nPŘÍJEMCE / RECIPIENT:\n{req.recipient.strip()}")
if req.signature.strip():
user_parts.append(f"\nPODPIS / SIGNATURE:\n{req.signature.strip()}")
if req.reply_to.strip():
user_parts.append(
f"\nPŮVODNÍ E-MAIL (pro reply) / ORIGINAL EMAIL TO REPLY TO:\n{req.reply_to.strip()}"
)
try:
resp = await _get_client().chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": _system_prompt(req.language, req.tone)},
{"role": "user", "content": "\n".join(user_parts)},
],
temperature=0.3,
max_tokens=2000,
)
except Exception as exc:
logger.exception("LLM call failed")
raise HTTPException(500, f"Generování selhalo: {exc}")
raw = (resp.choices[0].message.content or "").strip()
raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
logger.error("JSON parse failed: %s\nraw=%s", exc, raw[:500])
raise HTTPException(500, "Nepodařilo se zpracovat odpověď AI")
return {
"subject": data.get("subject", "").strip(),
"body": data.get("body", "").strip(),
}
@app.get("/health")
async def health():
return {"status": "ok"}
app.mount("/static", StaticFiles(directory="static"), name="static")

View File

@@ -0,0 +1,5 @@
fastapi>=0.115
uvicorn[standard]>=0.30
openai>=1.50
python-dotenv>=1.0
pydantic>=2.0

120
email-drafter/static/app.js Normal file
View File

@@ -0,0 +1,120 @@
// Email drafter frontend
(() => {
const $ = (id) => document.getElementById(id);
const sections = {
compose: $("s-compose"),
processing: $("s-processing"),
result: $("s-result"),
};
const show = (name) => {
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name);
};
// ── Persist signature & last-used tone/language in localStorage ──
const PERSIST_KEYS = ["signature", "tone", "language"];
for (const k of PERSIST_KEYS) {
const v = localStorage.getItem("email_" + k);
if (v !== null) $(k).value = v;
}
for (const k of PERSIST_KEYS) {
$(k).addEventListener("change", () =>
localStorage.setItem("email_" + k, $(k).value));
$(k).addEventListener("input", () =>
localStorage.setItem("email_" + k, $(k).value));
}
// ── Reply toggle ──
$("reply-toggle").addEventListener("click", () => {
const t = $("reply-toggle");
const p = $("reply-panel");
const expanded = t.getAttribute("aria-expanded") === "true";
t.setAttribute("aria-expanded", String(!expanded));
p.classList.toggle("hidden", expanded);
});
// ── Hint + button state ──
function updateHint() {
const notes = $("notes").value.trim();
const btn = $("generate-btn");
const hint = $("generate-hint");
if (notes.length < 5) {
btn.disabled = true;
hint.textContent = "Zadejte alespoň krátké poznámky.";
} else {
btn.disabled = false;
hint.textContent = `${notes.length} znaků zadáno. Klikněte pro vygenerování.`;
}
}
$("notes").addEventListener("input", updateHint);
updateHint();
// ── Generate ──
async function generate() {
const payload = {
notes: $("notes").value.trim(),
recipient: $("recipient").value.trim(),
signature: $("signature").value.trim(),
tone: $("tone").value,
language: $("language").value,
reply_to: $("reply-to").value.trim(),
};
if (payload.notes.length < 5) {
alert("Zadejte alespoň krátké poznámky.");
return;
}
show("processing");
try {
const r = await fetch("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) {
const errBody = await r.json().catch(() => ({ detail: r.statusText }));
throw new Error(errBody.detail || r.statusText);
}
const data = await r.json();
$("out-subject").value = data.subject || "";
$("out-body").value = data.body || "";
// Auto-resize body textarea to fit content
const ta = $("out-body");
ta.style.height = "auto";
ta.style.height = Math.max(280, ta.scrollHeight + 4) + "px";
show("result");
} catch (err) {
alert("Chyba: " + err.message);
show("compose");
}
}
$("generate-btn").addEventListener("click", generate);
$("regenerate-btn").addEventListener("click", generate);
$("back-btn").addEventListener("click", () => show("compose"));
// ── Copy buttons ──
function copyText(text, btn) {
navigator.clipboard.writeText(text).then(() => {
const original = btn.textContent;
btn.textContent = "Zkopírováno";
btn.classList.add("copied");
setTimeout(() => {
btn.textContent = original;
btn.classList.remove("copied");
}, 1500);
}).catch(() => alert("Kopírování selhalo. Vyberte text a stiskněte Ctrl+C."));
}
document.querySelectorAll(".btn-copy").forEach((btn) => {
btn.addEventListener("click", () => {
const target = $(btn.dataset.target);
copyText(target.value, btn);
});
});
$("copy-all-btn").addEventListener("click", (e) => {
const subject = $("out-subject").value;
const body = $("out-body").value;
const combined = `Předmět: ${subject}\n\n${body}`;
copyText(combined, e.target);
});
})();

View File

@@ -0,0 +1,201 @@
/* Email-drafter specific styles */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px 18px;
margin-bottom: 24px;
}
.form-row { display: flex; flex-direction: column; gap: 6px; }
.form-row-full { grid-column: 1 / -1; }
@media (max-width: 720px) {
.form-grid { grid-template-columns: 1fr; }
}
.form-label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.form-label-hint {
font-size: 11px;
font-weight: 400;
color: var(--text-quaternary);
text-transform: lowercase;
}
.form-input, .form-select, .form-textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-default);
background: var(--bg-primary);
color: var(--text-primary);
border-radius: 8px;
font-size: 14px;
font-family: inherit;
line-height: 1.5;
transition: border-color .15s, box-shadow .15s;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
.form-textarea { resize: vertical; min-height: 120px; }
.form-select { cursor: pointer; }
.reply-toggle {
background: transparent;
border: 1px dashed var(--border-strong);
color: var(--text-secondary);
padding: 10px 14px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-family: inherit;
text-align: left;
width: fit-content;
display: flex;
align-items: center;
gap: 8px;
transition: border-color .15s, color .15s;
}
.reply-toggle:hover { border-color: var(--primary); color: var(--primary); }
.reply-toggle[aria-expanded="true"] .reply-toggle-icon { transform: rotate(45deg); }
.reply-toggle-icon {
display: inline-block;
font-size: 16px;
line-height: 1;
transition: transform .2s;
}
.reply-panel { margin-top: 10px; }
.run-row {
display: flex;
align-items: center;
gap: 14px;
padding-top: 16px;
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); }
.processing-sub {
font-size: 13px;
color: var(--text-tertiary);
margin: 8px auto 0;
max-width: 360px;
}
/* Output email card */
.email-card {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 18px 20px;
margin-top: 16px;
box-shadow: var(--shadow-card);
}
.email-field { display: flex; flex-direction: column; gap: 8px; }
.email-field-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.email-field-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.email-subject-input {
width: 100%;
padding: 10px 12px;
border: 1px solid transparent;
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 8px;
font-size: 15px;
font-weight: 600;
font-family: inherit;
}
.email-subject-input:focus {
outline: none;
border-color: var(--primary);
background: var(--bg-primary);
}
.email-body-textarea {
width: 100%;
padding: 14px 16px;
border: 1px solid transparent;
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 8px;
font-size: 14px;
font-family: inherit;
line-height: 1.6;
resize: vertical;
min-height: 280px;
white-space: pre-wrap;
}
.email-body-textarea:focus {
outline: none;
border-color: var(--primary);
background: var(--bg-primary);
}
.email-divider {
height: 1px;
background: var(--border-default);
margin: 16px 0;
}
.email-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.btn-copy {
background: transparent;
border: 1px solid var(--border-default);
color: var(--text-secondary);
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
font-family: inherit;
transition: all .15s;
}
.btn-copy:hover {
border-color: var(--primary);
color: var(--primary);
}
.btn-copy.copied {
background: rgba(34, 197, 94, 0.12);
border-color: #16a34a;
color: #15803d;
}
/* Back link (shared with other apps) */
.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,181 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Návrh e-mailu | 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">Návrh e-mailu</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">
<section id="s-compose">
<div class="section-intro">
<h1 class="section-title">Návrh e-mailu z poznámek</h1>
<p class="section-desc">
Zadejte odrážky toho, co chcete napsat. AI z toho udělá uhlazený
profesionální e-mail.
</p>
</div>
<div class="form-grid">
<div class="form-row form-row-full">
<label class="form-label" for="notes">Poznámky / odrážky</label>
<textarea id="notes" class="form-textarea" rows="8" maxlength="4000"
placeholder="Co chcete v e-mailu sdělit? Stačí odrážky, nemusí být v celých větách.
Příklad:
- omlouvám se za pozdní reakci
- dohodli jsme se na termínu 24.5.
- pošlu fakturu do středy
- prosím o potvrzení dodací adresy"></textarea>
</div>
<div class="form-row">
<label class="form-label" for="recipient">
Příjemce / kontext
<span class="form-label-hint">nepovinné</span>
</label>
<input type="text" id="recipient" class="form-input" maxlength="200"
placeholder="např. „CEO partnerské firmy, formální vztah"">
</div>
<div class="form-row">
<label class="form-label" for="signature">
Podpis
<span class="form-label-hint">uloží se pro příště</span>
</label>
<input type="text" id="signature" class="form-input" maxlength="200"
placeholder="např. „S pozdravem, Ondřej Glaser, ředitel"">
</div>
<div class="form-row">
<label class="form-label" for="tone">Tón</label>
<select id="tone" class="form-select">
<option value="formal">Formální</option>
<option value="friendly">Přátelský</option>
<option value="firm">Důrazný</option>
<option value="apologetic">Omluvný</option>
</select>
</div>
<div class="form-row">
<label class="form-label" for="language">Jazyk</label>
<select id="language" class="form-select">
<option value="cs">Čeština</option>
<option value="en">Angličtina</option>
</select>
</div>
<div class="form-row form-row-full">
<button class="reply-toggle" id="reply-toggle" type="button" aria-expanded="false">
<span class="reply-toggle-icon">+</span>
Odpovídám na e-mail (vložit původní zprávu)
</button>
<div class="reply-panel hidden" id="reply-panel">
<textarea id="reply-to" class="form-textarea" rows="6" maxlength="6000"
placeholder="Vložte zde celý e-mail, na který odpovídáte. AI ho použije jako kontext pro vaši reakci."></textarea>
</div>
</div>
</div>
<div class="run-row">
<button class="btn btn-primary btn-lg" id="generate-btn" type="button">
Vygenerovat e-mail
</button>
<span class="run-hint" id="generate-hint">Zadejte alespoň krátké poznámky.</span>
</div>
</section>
<section id="s-processing" class="hidden">
<div class="processing-card">
<div class="spinner"></div>
<h2 class="processing-title">Píšu e-mail…</h2>
<p class="processing-sub">Obvykle 515 sekund.</p>
</div>
</section>
<section id="s-result" class="hidden">
<div class="results-header">
<div>
<h2 class="results-title">Návrh e-mailu</h2>
<p class="results-meta">Můžete upravit ručně a poté zkopírovat.</p>
</div>
<div class="results-actions">
<button class="btn btn-secondary" id="back-btn" type="button">
Upravit zadání
</button>
<button class="btn btn-secondary" id="regenerate-btn" type="button">
Vygenerovat znovu
</button>
</div>
</div>
<div class="email-card">
<div class="email-field">
<div class="email-field-row">
<label class="email-field-label" for="out-subject">Předmět</label>
<button class="btn-copy" data-target="out-subject" type="button">Kopírovat</button>
</div>
<input type="text" id="out-subject" class="email-subject-input">
</div>
<div class="email-divider"></div>
<div class="email-field">
<div class="email-field-row">
<label class="email-field-label" for="out-body">Tělo e-mailu</label>
<button class="btn-copy" data-target="out-body" type="button">Kopírovat</button>
</div>
<textarea id="out-body" class="email-body-textarea" rows="20"></textarea>
</div>
<div class="email-actions">
<button class="btn btn-primary" id="copy-all-btn" type="button">
Kopírovat předmět i tělo
</button>
</div>
</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; }

View File

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

View File

@@ -0,0 +1,18 @@
services:
feature-request:
build: .
container_name: feature-request
restart: unless-stopped
ports:
- "127.0.0.1:3032:8000"
environment:
STORAGE_DIR: /data/requests
volumes:
# Bind mount on the host so requests are easy to inspect/review
- /home/klas/Prace/AI/portal/feature-request/data:/data/requests
networks:
- localai
networks:
localai:
external: true

94
feature-request/main.py Normal file
View File

@@ -0,0 +1,94 @@
"""FastAPI: collect feature requests from portal users.
Stores each request to a timestamped folder on disk so the team can review
later. No LLM, no external calls — pure form handler.
"""
import datetime
import json
import logging
import os
import re
import uuid
from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="Feature Request")
app.add_middleware(CORSMiddleware, allow_origins=["*"],
allow_methods=["*"], allow_headers=["*"])
# Volume-mounted; survives container rebuilds.
STORAGE = Path(os.getenv("STORAGE_DIR", "/data/requests"))
STORAGE.mkdir(parents=True, exist_ok=True)
MAX_FILE_BYTES = 25 * 1024 * 1024 # 25 MB cap
def _slug(text: str) -> str:
text = re.sub(r"[^\w\s-]", "", text, flags=re.UNICODE).strip().lower()
text = re.sub(r"[\s_-]+", "-", text)
return text[:40] or "request"
@app.get("/")
async def root():
return FileResponse("static/index.html")
@app.post("/api/submit")
async def submit(
title: str = Form(..., min_length=3, max_length=120),
description: str = Form(..., min_length=10, max_length=8000),
name: str = Form("", max_length=120),
email: str = Form("", max_length=200),
file: UploadFile | None = File(None),
):
title = title.strip()
description = description.strip()
if not title or not description:
raise HTTPException(400, "Vyplňte název a popis")
now = datetime.datetime.now()
folder_name = f"{now.strftime('%Y%m%d_%H%M%S')}_{_slug(title)}_{uuid.uuid4().hex[:8]}"
folder = STORAGE / folder_name
folder.mkdir(parents=True, exist_ok=True)
record = {
"submitted_at": now.isoformat(timespec="seconds"),
"title": title,
"description": description,
"name": name.strip(),
"email": email.strip(),
"attachment": None,
}
if file is not None and file.filename:
raw = await file.read()
if len(raw) > MAX_FILE_BYTES:
raise HTTPException(400, f"Soubor je příliš velký (max {MAX_FILE_BYTES // (1024*1024)} MB)")
safe_name = re.sub(r"[^\w\.\-]", "_", file.filename)[:200]
attachment_path = folder / safe_name
attachment_path.write_bytes(raw)
record["attachment"] = safe_name
record["attachment_bytes"] = len(raw)
record["attachment_content_type"] = file.content_type or ""
(folder / "request.json").write_text(
json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8"
)
logger.info("Stored feature request → %s (%s)", folder_name, title)
return {"ok": True, "id": folder_name}
@app.get("/health")
async def health():
return {"status": "ok"}
app.mount("/static", StaticFiles(directory="static"), name="static")

View File

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

View File

@@ -0,0 +1,95 @@
// Feature request form
(() => {
const $ = (id) => document.getElementById(id);
const sections = {
form: $("s-form"),
processing: $("s-processing"),
thanks: $("s-thanks"),
};
const show = (name) => {
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name);
};
// Persist name + email for next submission
for (const k of ["name", "email"]) {
const v = localStorage.getItem("req_" + k);
if (v) $(k).value = v;
$(k).addEventListener("input", () => localStorage.setItem("req_" + k, $(k).value));
}
// ── File handling ──
const fileInput = $("file-input");
const dropZone = $("file-drop");
const dropText = $("file-drop-text");
let attachedFile = null;
function setFile(f) {
if (!f) return;
attachedFile = f;
const size = f.size > 1024 * 1024
? `${(f.size / 1024 / 1024).toFixed(1)} MB`
: `${(f.size / 1024).toFixed(0)} kB`;
dropZone.classList.add("has-file");
dropText.innerHTML = `
<span class="file-pill">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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>
${escapeHtml(f.name)} <span style="color:var(--text-tertiary)">(${size})</span>
<button type="button" class="file-pill-clear" id="file-clear">×</button>
</span>
`;
$("file-clear").addEventListener("click", clearFile);
}
function clearFile() {
attachedFile = null;
fileInput.value = "";
dropZone.classList.remove("has-file");
dropText.innerHTML = `Přetáhněte soubor sem nebo
<button type="button" class="btn-link" id="file-pick-btn">vyberte z disku</button>`;
$("file-pick-btn").addEventListener("click", () => fileInput.click());
}
$("file-pick-btn").addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", (e) => 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.preventDefault(); setFile(e.dataTransfer.files[0]); });
// ── Submit ──
$("request-form").addEventListener("submit", async (e) => {
e.preventDefault();
show("processing");
try {
const fd = new FormData();
fd.append("title", $("title").value);
fd.append("description", $("description").value);
fd.append("name", $("name").value);
fd.append("email", $("email").value);
if (attachedFile) fd.append("file", attachedFile);
const r = await fetch("/api/submit", { method: "POST", body: fd });
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
show("thanks");
} catch (err) {
alert("Chyba: " + err.message);
show("form");
}
});
$("another-btn").addEventListener("click", () => {
$("title").value = "";
$("description").value = "";
clearFile();
show("form");
});
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
})();

View File

@@ -0,0 +1,164 @@
/* Feature-request app styles */
.request-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 640px) { .form-grid { grid-template-columns: 1fr; } }
.form-row { display: flex; flex-direction: column; gap: 6px; }
.form-label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.form-label-hint {
font-size: 11px;
font-weight: 400;
color: var(--text-quaternary);
}
.form-input, .form-textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-default);
background: var(--bg-primary);
color: var(--text-primary);
border-radius: 8px;
font-size: 14px;
font-family: inherit;
line-height: 1.5;
transition: border-color .15s, box-shadow .15s;
}
.form-input:focus, .form-textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
.form-textarea { resize: vertical; min-height: 160px; }
.file-drop {
padding: 24px;
border: 1.5px dashed var(--border-strong);
border-radius: 8px;
background: var(--bg-secondary);
text-align: center;
font-size: 13px;
color: var(--text-tertiary);
transition: border-color .15s, background .15s;
cursor: default;
}
.file-drop.drag-over {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 5%, var(--bg-secondary));
}
.file-drop.has-file {
border-style: solid;
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, var(--bg-secondary));
text-align: left;
}
.file-drop-text { color: var(--text-secondary); }
.btn-link {
background: transparent;
border: none;
color: var(--primary);
cursor: pointer;
font-size: inherit;
font-family: inherit;
text-decoration: underline;
padding: 0;
}
.btn-link:hover { color: var(--primary-hover); }
.file-pill {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-primary);
}
.file-pill-clear {
background: transparent;
border: none;
color: var(--text-quaternary);
cursor: pointer;
font-size: 14px;
padding: 2px 6px;
border-radius: 4px;
}
.file-pill-clear:hover { color: #dc2626; background: rgba(239,68,68,0.08); }
.form-actions {
display: flex;
align-items: center;
gap: 14px;
padding-top: 16px;
border-top: 1px solid var(--border-default);
flex-wrap: wrap;
}
.btn-lg { padding: 12px 26px; font-size: 14px; font-weight: 600; }
.run-hint { font-size: 12.5px; color: var(--text-tertiary); }
/* Thanks card */
.thanks-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);
}
.thanks-icon {
width: 56px;
height: 56px;
color: #16a34a;
margin: 0 auto 18px;
}
.thanks-title {
font-size: 22px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.thanks-text {
font-size: 14px;
color: var(--text-secondary);
max-width: 480px;
margin: 0 auto 28px;
line-height: 1.55;
}
.thanks-actions {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
/* 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; }
}

View File

@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Návrh nového nástroje | 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">Návrh nového nástroje</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">
<section id="s-form">
<div class="section-intro">
<h1 class="section-title">Chybí vám nějaký nástroj?</h1>
<p class="section-desc">
Popište, co byste potřebovali. Pokud máte ukázkový soubor
(faktura, smlouva, výkres…), přiložte ho — pomůže nám pochopit
kontext. Návrh se uloží a my se na něj podíváme.
</p>
</div>
<form id="request-form" class="request-form">
<div class="form-row">
<label class="form-label" for="title">Krátký název *</label>
<input type="text" id="title" name="title" class="form-input" required
minlength="3" maxlength="120"
placeholder="např. „Generování zápisů z porad"">
</div>
<div class="form-row">
<label class="form-label" for="description">Popis *</label>
<textarea id="description" name="description" class="form-textarea"
required minlength="10" maxlength="8000" rows="8"
placeholder="Co by tento nástroj měl umět? Jaký problém řeší? Jak ho budete používat?
Klidně i v odrážkách — nemusí být napsané dokonale."></textarea>
</div>
<div class="form-grid">
<div class="form-row">
<label class="form-label" for="name">
Vaše jméno
<span class="form-label-hint">nepovinné</span>
</label>
<input type="text" id="name" name="name" class="form-input"
maxlength="120" autocomplete="name">
</div>
<div class="form-row">
<label class="form-label" for="email">
E-mail
<span class="form-label-hint">nepovinné — pro zpětnou vazbu</span>
</label>
<input type="email" id="email" name="email" class="form-input"
maxlength="200" autocomplete="email"
placeholder="vase.jmeno@colsys.cz">
</div>
</div>
<div class="form-row">
<label class="form-label">
Ukázkový soubor
<span class="form-label-hint">nepovinné · max 25 MB</span>
</label>
<div id="file-drop" class="file-drop">
<span class="file-drop-text" id="file-drop-text">
Přetáhněte soubor sem nebo
<button type="button" class="btn-link" id="file-pick-btn">vyberte z disku</button>
</span>
<input type="file" id="file-input" name="file" style="display:none">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary btn-lg" id="submit-btn">
Odeslat návrh
</button>
<span class="run-hint">Návrhy ukládáme interně — uvidí je jen tým, který portál vyvíjí.</span>
</div>
</form>
</section>
<section id="s-processing" class="hidden">
<div class="processing-card">
<div class="spinner"></div>
<h2 class="processing-title">Odesílám návrh…</h2>
</div>
</section>
<section id="s-thanks" class="hidden">
<div class="thanks-card">
<svg class="thanks-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>
<h2 class="thanks-title">Děkujeme!</h2>
<p class="thanks-text">
Návrh byl uložen. Podíváme se na něj a pokud má smysl, ozveme se
nebo rovnou přidáme nástroj do portálu.
</p>
<div class="thanks-actions">
<a href="https://ai.klas.chat" class="btn btn-primary">Zpět na portál</a>
<button class="btn btn-secondary" id="another-btn" type="button">Odeslat další návrh</button>
</div>
</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; }

View File

@@ -0,0 +1,89 @@
Jsi asistent pro kontrolu interní kalkulace cenových nabídek v Excelu. Tvým úkolem je najít všechny listy typu „VV“ a zkontrolovat, jestli se stejné položky (dle názvu) oceňují ve všech těchto listech stejnou jednotkovou cenou.
ROLE
Jsi technický kontrolor cen v interní kalkulaci pro elektro / MaR projekty. Pracuješ s jedním Excel sešitem, který obsahuje více listů (některé jsou VV, jiné jsou pracovní listy, ceníky apod.).
ÚKOL
Najdi v sešitu všechny listy, které jsou výkazy výměr (VV).
Z těchto listů vytáhni položky a porovnej položky se stejným názvem napříč všemi VV.
Pokud má položka se stejným názvem v různých VV jinou jednotkovou cenu, zapiš tento nesoulad do přehledné reportovací tabulky.
VSTUPY
Jeden Excel sešit s interní kalkulací nabídky.
V sešitě je více listů; jen některé jsou VV (výkaz výměr).
WORKFLOW
Identifikace listů VV
Projdi všechny listy v sešitu.
List považuj za VV, pokud to odpovídá názvu nebo struktuře (např. název obsahuje „VV“ nebo list má typickou tabulku VV: číslo položky, název, MJ, množství, jednotková cena apod.).
Ostatní listy (ceníky, pomocné kalkulace atd.) ignoruj.
Načtení položek z VV
Z každého listu VV si načti všechny řádky s položkami (ignoruj součty, mezisoučty, nadpisy sekcí apod.).
U každé položky ulož minimálně:
název položky (text),
jednotkovou cenu,
měnu (pokud je),
název/list VV, ze kterého pochází.
Porovnání položek podle názvu
Vytvoř seznam všech názvů položek, které se vyskytují alespoň na dvou různých listech VV.
Pro každý takový název porovnej jednotkové ceny ve všech listech VV, kde se vyskytuje.
Zohledni, že položky musí být skutečně totožné porovnávej přesný název (včetně diakritiky a mezer). Pokud je zjevná drobná formální odchylka (např. jiný počet mezer), položku můžeš přesto považovat za stejnou, pokud je význam jasně identický.
Detekce nesouladů
Pokud má stejný název položky ve dvou nebo více VV různou jednotkovou cenu, označ to jako nesoulad.
Nesoulad hlásíš pouze v případě rozdílné jednotkové ceny; rozdílné množství neřeš (to může být různé pro každou zakázku / část).
Výstupní tabulka (report)
Připrav přehlednou tabulku s nesouladnými položkami ve struktuře (v češtině):
Název položky
List VV (název listu)
Jednotková cena
Jednotka (pokud je)
Poznámka (např. „jiná cena než na listu VV_XYZ“)
Tabulka by měla obsahovat všechny kombinace „název položky + list“, kde se cena liší. Můžeš například uložit každou položku jako samostatný řádek a v poznámce popsat, k čemu je rozdíl.
Shrnutí
Na konec přidej krátké slovní shrnutí: kolik různých názvů položek má rozdílné ceny a kolik řádků v reportu vzniklo.
Pokud žádný nesoulad nenajdeš, napiš jasně, že všechny položky se stejným názvem mají shodné jednotkové ceny ve všech VV.
PRAVIDLA
Neměň žádná data v originálním souboru, jen je čti a analyzuj.
Nepracuj s nákladovými interními cenami, ceníky ani jinými pracovními listy, pokud nejsou výslovně označené jako VV.
Pokud si nejsi jistý, zda je list VV, raději ho popiš v poznámce a požádej o potvrzení.
Odpovídej česky.
VÝSTUP
Tabulka s nesouladnými položkami ve formátu vhodném pro vložení do Excelu (markdown tabulka nebo CSV struktura).
Stručné slovní shrnutí zjištěných rozdílů.

View File

@@ -0,0 +1,83 @@
# Instrukce projektu „Změna ve VV"
Zkopírujte text níže (mezi čarami) do nastavení projektu v Cowork (Project instructions).
---
Asistent pro porovnání výkazu výměr (VV) ve formátu MaR (Měření a regulace).
**Vstupy:**
- Vždy načti **původní** soubor (typicky pojmenovaný „_stávající_…" nebo „…původní…").
- Vždy načti **nový** soubor (typicky „_nový_…" nebo „…změna…").
- Pokud uživatel nahraje soubory s jinými názvy, použij je v pořadí: první nahraný = původní, druhý = nový (a tuto domněnku v odpovědi explicitně uveď).
- Soubory mají typicky více listů (např. PS561, PS561 (2), PS561 (3), PS561 (4)) — zpracuj **všechny odpovídající listy** z obou souborů.
**Struktura zdrojových listů:**
- Řádky 15: hlavička s názvem haly/objektu (řádek 4, sloupec C).
- Řádek 6: hlavička tabulky — Poř. | Kód | Popis | MJ | Výměra | Jedn. cena | Cena.
- Od řádku 8: data, kde sekce („001: Rozvaděče", „002: Koncové prvky", …) jsou rozpoznatelné podle prázdné MJ a popisu obsahujícího dvojtečku.
**Definice „změny":**
- **Změněná položka** = stejný popis a sekce v obou souborech, ale liší se **výměra** nebo **MJ**. Rozdíly v jednotkové ceně se NEPOČÍTAJÍ jako změna (nový VV obvykle ceny neobsahuje).
- **Přidaná položka** = je v novém VV, ale ne v původním (párování podle sekce + popis).
- **Odebraná položka** = byla v původním VV, ale v novém už není.
**Hlavní úkol — vytvoř Excel soubor `Porovnání_VV_původní_vs_nový.xlsx` se 4 listy:**
1. **Souhrn** (bez cen)
- Modrý nadpis přes celou šířku, podtitul s názvy souborů (kurzíva, šedá).
- Tabulka: List | Hala / objekt | Počet pol. (původní) | Počet pol. (nový) | Změněné položky | Přidané položky | Odebrané položky.
- Modré záhlaví tabulky (RGB: 1F4E78), bílý tučný text Arial 11.
- Buňky se změněnými/přidanými/odebranými hodnotami zvýraznit barevně podle typu (žlutá / zelená / červená — viz níže), tučně, pokud je hodnota > 0.
- Závěrečný řádek „CELKEM" se sumami (formule SUM), šedé pozadí (RGB: F2F2F2), tučně.
- Pod tabulkou box „Rekapitulace změn (celkem)" se třemi řádky: Změněné / Přidané / Odebrané + počty.
- Vysvětlující poznámka kurzívou: „Za „změnu" je považován pouze rozdíl ve výměře nebo MJ. Cenové rozdíly se ignorují, protože nový VV obvykle ceny neobsahuje."
2. **Změny** — list s rozdíly ve výměře / MJ
- Sloupce: List | Hala | Sekce | Popis položky | MJ orig. | MJ nová | Výměra orig. | Výměra nová | Rozdíl výměra | Jed. cena orig. | Cena orig. | Cena nová.
- Buňka „Rozdíl výměra" zvýrazněna: zeleně (RGB: E4F0DC) pokud kladný, červeně (RGB: FCE4E4) pokud záporný, tučně.
- Freeze panes na řádek 4, autofilter.
3. **Přidané položky**
- Sloupce: List | Hala | Sekce | Popis položky | MJ | Výměra | Jed. cena | Cena.
- Celé řádky podbarvené zeleně (RGB: D9EAD3).
4. **Odebrané položky**
- Stejné sloupce jako Přidané.
- Celé řádky podbarvené červeně (RGB: F4CCCC).
- Na konci součet sloupce Cena (formule SUM), šedé pozadí, tučně.
**Společné formátování:**
- Font Arial, body 10 (záhlaví 11).
- Tenké šedé ohraničení (RGB: BFBFBF) všech buněk s daty.
- Měna: `#,##0.00 "Kč";[Red]-#,##0.00 "Kč";"-"`.
- Čísla: `#,##0.##;[Red]-#,##0.##;"-"`.
- Modrý nadpis na každém listu: Arial bold 14, RGB: 1F4E78.
- Po vytvoření vždy spustit `recalc.py` a ověřit nulové chyby formulí.
**Barvy (souhrn):**
- Modré záhlaví: 1F4E78 / bílý text
- Žlutá (změny): FFF2CC
- Zelená (přidáno): D9EAD3
- Červená (odebráno): F4CCCC
- Tmavě zelený text: 006100
- Tmavě červený text: C00000
- Šedý titulek/poznámka: 595959, kurzíva
- Šedý součet: F2F2F2
**Výstup:**
- Uložit do složky projektu jako `Porovnání_VV_původní_vs_nový.xlsx`.
- V chatu uvést stručné shrnutí: počty změněných / přidaných / odebraných položek, zmínit nejvýraznější změny (výměry, které se změnily o více než řád), upozornit pokud nový VV nemá doplněné jednotkové ceny.
- Odkaz na vytvořený soubor (computer:// link).
**Co NEdělat:**
- NEpočítat za změnu rozdíl pouze v jednotkové ceně, pokud se výměra/MJ nezměnila.
- NEcharakterizovat položky s prázdnou cenou v novém VV jako „odebrané" — ty nejsou odebrané.
- NEvytvářet souhrny celkových cen v listu Souhrn — celkové ceny v novém VV obvykle vychází 0 a souhrn by byl matoucí. Cenové údaje patří jen do detailních listů (Změny / Přidané / Odebrané).
- NEpřidávat další listy nad rámec čtyř výše uvedených, pokud o to uživatel výslovně nepožádá.
---
## Pokud se struktura souborů liší od očekávané
Pokud nahrané soubory mají neočekávanou strukturu (např. jiný list, jiný layout), nejprve se uživatele zeptej (AskUserQuestion), které listy má porovnat — neodhazuj data automaticky.

View File

@@ -0,0 +1,3 @@
LITELLM_BASE_URL=http://host.docker.internal:4000/v1
LITELLM_API_KEY=sk-...
LLM_MODEL=anthropic/claude-sonnet-4-20250514

View File

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

View File

@@ -0,0 +1,24 @@
services:
invoice-extractor:
build: .
container_name: invoice-extractor
restart: unless-stopped
ports:
- "127.0.0.1:3029: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:
- invoice-extractor-data:/tmp/invoice-extractor
volumes:
invoice-extractor-data:
networks:
localai:
external: true

View File

@@ -0,0 +1,154 @@
"""Render extracted invoice data into a tidy XLSX file."""
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
HEADER_FILL = PatternFill("solid", fgColor="2563EB")
HEADER_FONT = Font(bold=True, color="FFFFFF", size=11)
LABEL_FONT = Font(bold=True, size=10)
THIN = Side(style="thin", color="D0D5DC")
BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
def write_invoice_xlsx(data: dict, out_path: str) -> None:
wb = Workbook()
ws = wb.active
ws.title = "Faktura"
# ── Header section ──
ws.cell(row=1, column=1, value="FAKTURA — extrahovaná data").font = Font(bold=True, size=14)
ws.merge_cells("A1:D1")
row = 3
row = _write_kv_block(ws, row, "Identifikace faktury", [
("Číslo faktury", data.get("invoice_number")),
("Variabilní symbol", data.get("variable_symbol")),
("Konstantní symbol", data.get("constant_symbol")),
("Specifický symbol", data.get("specific_symbol")),
("Datum vystavení", data.get("issue_date")),
("Datum splatnosti", data.get("due_date")),
("DUZP", data.get("taxable_date")),
("Měna", data.get("currency") or "CZK"),
("Způsob platby", data.get("payment_method")),
("Číslo účtu", data.get("bank_account")),
("IBAN", data.get("iban")),
])
row += 1
sup = data.get("supplier") or {}
row = _write_kv_block(ws, row, "Dodavatel", [
("Název", sup.get("name")),
("IČO", sup.get("ico")),
("DIČ", sup.get("dic")),
("Adresa", sup.get("address")),
])
row += 1
cust = data.get("customer") or {}
row = _write_kv_block(ws, row, "Odběratel", [
("Název", cust.get("name")),
("IČO", cust.get("ico")),
("DIČ", cust.get("dic")),
("Adresa", cust.get("address")),
])
row += 1
# ── Line items table ──
items = data.get("line_items") or []
if items:
row = _write_section_header(ws, row, "Položky faktury")
headers = ["Popis", "Množství", "Jednotka", "Cena/jed. bez DPH",
"Sazba DPH (%)", "Bez DPH", "S DPH"]
for c, h in enumerate(headers, 1):
cell = ws.cell(row=row, column=c, value=h)
cell.font = HEADER_FONT
cell.fill = HEADER_FILL
cell.alignment = Alignment(horizontal="center")
cell.border = BORDER
row += 1
for item in items:
values = [
item.get("description") or "",
item.get("quantity"),
item.get("unit") or "",
item.get("unit_price_excluding_vat"),
item.get("vat_rate"),
item.get("total_excluding_vat"),
item.get("total_including_vat"),
]
for c, v in enumerate(values, 1):
cell = ws.cell(row=row, column=c, value=v)
cell.border = BORDER
if c >= 4 and isinstance(v, (int, float)):
cell.number_format = "#,##0.00"
row += 1
row += 1
# ── VAT breakdown ──
vat = data.get("vat_breakdown") or []
if vat:
row = _write_section_header(ws, row, "Rekapitulace DPH")
headers = ["Sazba (%)", "Základ", "DPH", "Celkem"]
for c, h in enumerate(headers, 1):
cell = ws.cell(row=row, column=c, value=h)
cell.font = HEADER_FONT
cell.fill = HEADER_FILL
cell.alignment = Alignment(horizontal="center")
cell.border = BORDER
row += 1
for br in vat:
for c, v in enumerate([br.get("rate"), br.get("base"),
br.get("vat"), br.get("total")], 1):
cell = ws.cell(row=row, column=c, value=v)
cell.border = BORDER
if c >= 2 and isinstance(v, (int, float)):
cell.number_format = "#,##0.00"
row += 1
row += 1
# ── Totals ──
row = _write_section_header(ws, row, "Celkem")
totals = [
("Celkem bez DPH", data.get("total_excluding_vat")),
("Celkem DPH", data.get("total_vat")),
("CELKEM K ÚHRADĚ", data.get("total_including_vat")),
]
for label, val in totals:
ws.cell(row=row, column=1, value=label).font = LABEL_FONT
cell = ws.cell(row=row, column=2, value=val)
if isinstance(val, (int, float)):
cell.number_format = "#,##0.00"
if label.startswith("CELKEM K"):
ws.cell(row=row, column=1).font = Font(bold=True, size=12)
cell.font = Font(bold=True, size=12)
row += 1
if data.get("notes"):
row += 1
ws.cell(row=row, column=1, value="Poznámka:").font = LABEL_FONT
ws.cell(row=row, column=2, value=data["notes"])
# ── Auto-widths ──
widths = {1: 32, 2: 14, 3: 10, 4: 16, 5: 14, 6: 14, 7: 14}
for c, w in widths.items():
ws.column_dimensions[get_column_letter(c)].width = w
wb.save(out_path)
def _write_section_header(ws, row: int, text: str) -> int:
cell = ws.cell(row=row, column=1, value=text)
cell.font = Font(bold=True, size=12, color="0F1729")
cell.fill = PatternFill("solid", fgColor="EFF4FF")
ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=7)
return row + 1
def _write_kv_block(ws, row: int, header: str, pairs: list[tuple]) -> int:
row = _write_section_header(ws, row, header)
for label, val in pairs:
ws.cell(row=row, column=1, value=label).font = LABEL_FONT
ws.cell(row=row, column=2, value=val if val is not None else "")
row += 1
return row

View File

@@ -0,0 +1,190 @@
"""Invoice data extraction via Claude vision."""
import base64
import io
import json
import logging
import os
from pathlib import Path
import fitz # PyMuPDF
from PIL import Image
from openai import AsyncOpenAI
logger = logging.getLogger(__name__)
_client: AsyncOpenAI | None = None
MODEL = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-20250514")
MAX_PAGES = 6 # cap to keep token cost predictable
MAX_IMG_LONG_EDGE = 2200 # px — enough resolution for invoice details
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
SYSTEM_PROMPT = """Jste přesný extraktor dat z českých faktur. Z obrázku/obrázků faktury vyextrahujete strukturovaná data pro účetní systém.
Vraťte POUZE platný JSON v tomto tvaru, žádné markdown obaly, žádný komentář mimo JSON:
{
"invoice_number": "string|null",
"issue_date": "YYYY-MM-DD|null",
"due_date": "YYYY-MM-DD|null",
"taxable_date": "YYYY-MM-DD|null",
"variable_symbol": "string|null",
"constant_symbol": "string|null",
"specific_symbol": "string|null",
"currency": "CZK",
"supplier": {
"name": "string|null",
"ico": "string|null",
"dic": "string|null",
"address": "string|null"
},
"customer": {
"name": "string|null",
"ico": "string|null",
"dic": "string|null",
"address": "string|null"
},
"bank_account": "string|null",
"iban": "string|null",
"payment_method": "převod|hotově|karta|dobírka|jiné|null",
"line_items": [
{
"description": "string",
"quantity": "number|null",
"unit": "string|null",
"unit_price_excluding_vat": "number|null",
"vat_rate": "number|null",
"total_excluding_vat": "number|null",
"total_including_vat": "number|null"
}
],
"vat_breakdown": [
{"rate": 21, "base": 0.0, "vat": 0.0, "total": 0.0}
],
"total_excluding_vat": "number|null",
"total_vat": "number|null",
"total_including_vat": "number|null",
"notes": "string|null"
}
Pravidla:
- Datumy zapisujte ve formátu YYYY-MM-DD (ISO 8601).
- Čísla zapisujte jako desetinná čísla s tečkou jako oddělovačem (např. 1234.56). NIKDY nepoužívejte čárku ani mezery v číslech.
- Pokud údaj na faktuře není, použijte null. NEVYMÝŠLEJTE si.
- IČO je 8místné číslo (může mít vedoucí nuly). DIČ obvykle začíná „CZ".
- variable_symbol je obvykle stejný jako číslo faktury, ale ne vždy — zapisujte přesně co je na faktuře.
- U položek (line_items) zachovejte přesné pořadí jak jsou na faktuře.
- Pokud je faktura v jiné měně než CZK, zapište správný kód měny (EUR, USD, atd.).
- Adresy zapisujte jako jeden řetězec (ulice + číslo, PSČ město)."""
async def extract_invoice(pdf_or_image_path: Path) -> dict:
"""Extract structured invoice data using Claude vision."""
images = _to_images(pdf_or_image_path)
if not images:
raise RuntimeError("Soubor neobsahuje žádné zobrazitelné stránky")
content = [
{"type": "text",
"text": "Z následujících obrázků faktury vyextrahujte strukturovaná data podle definovaného JSON tvaru."},
]
for img_b64 in images:
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{img_b64}"},
})
try:
resp = await _get_client().chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": content},
],
temperature=0.0,
max_tokens=4000,
)
except Exception as exc:
logger.exception("LLM call failed")
raise RuntimeError(f"AI extrakce selhala: {exc}")
raw = (resp.choices[0].message.content or "").strip()
raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
logger.error("JSON parse failed: %s\n%s", exc, raw[:500])
raise RuntimeError(f"Nepodařilo se zpracovat odpověď AI: {exc}")
return _normalize(data)
def _to_images(path: Path) -> list[str]:
"""Return list of base64-encoded PNG strings, one per page."""
suffix = path.suffix.lower()
if suffix == ".pdf":
doc = fitz.open(str(path))
out = []
for i, page in enumerate(doc):
if i >= MAX_PAGES:
break
pix = page.get_pixmap(dpi=200, alpha=False)
img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
out.append(_compress(img))
doc.close()
return out
elif suffix in (".jpg", ".jpeg", ".png", ".webp"):
img = Image.open(path).convert("RGB")
return [_compress(img)]
raise RuntimeError(f"Nepodporovaný formát: {suffix}")
def _compress(img: Image.Image) -> str:
if max(img.size) > MAX_IMG_LONG_EDGE:
ratio = MAX_IMG_LONG_EDGE / max(img.size)
img = img.resize(
(int(img.size[0] * ratio), int(img.size[1] * ratio)),
Image.LANCZOS,
)
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=True)
return base64.b64encode(buf.getvalue()).decode("ascii")
def _normalize(data: dict) -> dict:
"""Coerce known numeric fields to float and clean nulls."""
def _num(v):
if v is None or v == "":
return None
if isinstance(v, (int, float)):
return float(v)
try:
return float(str(v).replace(",", ".").replace(" ", ""))
except (ValueError, TypeError):
return None
for k in ("total_excluding_vat", "total_vat", "total_including_vat"):
if k in data:
data[k] = _num(data[k])
for item in data.get("line_items") or []:
for k in ("quantity", "unit_price_excluding_vat", "vat_rate",
"total_excluding_vat", "total_including_vat"):
if k in item:
item[k] = _num(item[k])
for br in data.get("vat_breakdown") or []:
for k in ("rate", "base", "vat", "total"):
if k in br:
br[k] = _num(br[k])
return data

98
invoice-extractor/main.py Normal file
View File

@@ -0,0 +1,98 @@
"""FastAPI: invoice PDF/image → structured data → editable form → XLSX export."""
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 excel_export import write_invoice_xlsx
from extractor import extract_invoice
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="Invoice Extractor")
app.add_middleware(CORSMiddleware, allow_origins=["*"],
allow_methods=["*"], allow_headers=["*"])
WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/invoice-extractor"))
WORK_DIR.mkdir(parents=True, exist_ok=True)
jobs: dict[str, dict] = {}
ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png", ".webp"}
@app.get("/")
async def root():
return FileResponse("static/index.html")
@app.post("/api/upload")
async def upload(file: UploadFile = File(...)):
suffix = Path(file.filename or "").suffix.lower()
if suffix not in ALLOWED_EXT:
raise HTTPException(400, f"Podporované formáty: {', '.join(sorted(ALLOWED_EXT))}")
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:
data = await extract_invoice(input_path)
except Exception as exc:
logger.exception("Extraction failed")
raise HTTPException(500, str(exc))
jobs[job_id] = {
"filename": file.filename,
"job_dir": str(job_dir),
"data": data,
}
return {"job_id": job_id, "data": data}
class SaveRequest(BaseModel):
data: dict
@app.post("/api/save/{job_id}")
async def save_data(job_id: str, req: SaveRequest):
if job_id not in jobs:
raise HTTPException(404, "Nenalezeno")
jobs[job_id]["data"] = req.data
return {"ok": True}
@app.get("/api/export/{job_id}")
async def export(job_id: str):
if job_id not in jobs:
raise HTTPException(404, "Nenalezeno")
job = jobs[job_id]
out_path = Path(job["job_dir"]) / "invoice.xlsx"
write_invoice_xlsx(job["data"], str(out_path))
inv_no = (job["data"].get("invoice_number") or
Path(job["filename"]).stem if job.get("filename") else "faktura")
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in str(inv_no))
return FileResponse(
str(out_path),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=f"faktura_{safe}.xlsx",
)
@app.get("/health")
async def health():
return {"status": "ok"}
app.mount("/static", StaticFiles(directory="static"), name="static")

View File

@@ -0,0 +1,8 @@
fastapi>=0.115
uvicorn[standard]>=0.30
PyMuPDF>=1.24
Pillow>=10.0
openpyxl>=3.1
python-multipart>=0.0.9
openai>=1.50
python-dotenv>=1.0

View File

@@ -0,0 +1,352 @@
// Invoice extractor frontend
(() => {
const $ = (id) => document.getElementById(id);
const sections = {
upload: $("s-upload"),
processing: $("s-processing"),
result: $("s-result"),
};
const show = (name) => {
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name);
};
let state = { jobId: null, data: 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/upload", { 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.data = json.data || {};
renderForm();
show("result");
} catch (err) {
alert("Chyba: " + err.message);
show("upload");
}
}
// ── Render editable form ──
function field(key, label, value, opts = {}) {
const isEmpty = value === null || value === undefined || value === "";
const display = isEmpty ? "" : value;
const cls = "inv-input" + (isEmpty ? " is-empty" : "");
return `
<div class="inv-field ${opts.wide ? "inv-field-wide" : ""}">
<label class="inv-label">${escapeHtml(label)}</label>
<input class="${cls}" data-key="${key}" type="${opts.type || "text"}"
value="${escapeHtmlAttr(display)}"
placeholder="${escapeHtmlAttr(opts.placeholder || "—")}">
</div>`;
}
function nestedField(parent, key, label, value, opts = {}) {
const isEmpty = value === null || value === undefined || value === "";
const display = isEmpty ? "" : value;
const cls = "inv-input" + (isEmpty ? " is-empty" : "");
return `
<div class="inv-field ${opts.wide ? "inv-field-wide" : ""}">
<label class="inv-label">${escapeHtml(label)}</label>
<input class="${cls}" data-parent="${parent}" data-key="${key}"
type="${opts.type || "text"}"
value="${escapeHtmlAttr(display)}"
placeholder="${escapeHtmlAttr(opts.placeholder || "—")}">
</div>`;
}
function renderForm() {
const d = state.data;
const sup = d.supplier || {};
const cust = d.customer || {};
const items = d.line_items || [];
const vat = d.vat_breakdown || [];
const html = `
<!-- Identifikace -->
<div class="inv-card">
<div class="inv-card-title">Identifikace faktury</div>
<div class="inv-grid-3">
${field("invoice_number", "Číslo faktury", d.invoice_number)}
${field("variable_symbol", "Variabilní symbol", d.variable_symbol)}
${field("currency", "Měna", d.currency || "CZK")}
${field("issue_date", "Datum vystavení", d.issue_date, {type: "date"})}
${field("due_date", "Datum splatnosti", d.due_date, {type: "date"})}
${field("taxable_date", "DUZP", d.taxable_date, {type: "date"})}
${field("constant_symbol", "Konstantní symbol", d.constant_symbol)}
${field("specific_symbol", "Specifický symbol", d.specific_symbol)}
${field("payment_method", "Způsob platby", d.payment_method)}
</div>
</div>
<!-- Dodavatel -->
<div class="inv-card">
<div class="inv-card-title">Dodavatel</div>
<div class="inv-grid">
${nestedField("supplier", "name", "Název", sup.name, {wide: true})}
${nestedField("supplier", "ico", "IČO", sup.ico)}
${nestedField("supplier", "dic", "DIČ", sup.dic)}
${nestedField("supplier", "address", "Adresa", sup.address, {wide: true})}
</div>
</div>
<!-- Odběratel -->
<div class="inv-card">
<div class="inv-card-title">Odběratel</div>
<div class="inv-grid">
${nestedField("customer", "name", "Název", cust.name, {wide: true})}
${nestedField("customer", "ico", "IČO", cust.ico)}
${nestedField("customer", "dic", "DIČ", cust.dic)}
${nestedField("customer", "address", "Adresa", cust.address, {wide: true})}
</div>
</div>
<!-- Platba -->
<div class="inv-card">
<div class="inv-card-title">Platební údaje</div>
<div class="inv-grid">
${field("bank_account", "Číslo účtu", d.bank_account)}
${field("iban", "IBAN", d.iban)}
</div>
</div>
<!-- Položky -->
<div class="inv-card">
<div class="inv-card-title">Položky faktury</div>
<div class="inv-table-wrap">
<table class="inv-table" id="items-table">
<thead>
<tr>
<th style="min-width:200px">Popis</th>
<th style="width:80px">Množství</th>
<th style="width:70px">Jednotka</th>
<th style="width:110px">Cena/jed. bez DPH</th>
<th style="width:70px">DPH %</th>
<th style="width:110px">Bez DPH</th>
<th style="width:110px">S DPH</th>
<th class="inv-row-actions"></th>
</tr>
</thead>
<tbody id="items-body">
${items.map((it, i) => renderItemRow(it, i)).join("")}
</tbody>
</table>
</div>
<div class="inv-table-foot">
<button class="btn-add-row" type="button" id="add-item-btn">+ Přidat položku</button>
</div>
</div>
${vat.length ? `
<!-- Rekapitulace DPH -->
<div class="inv-card">
<div class="inv-card-title">Rekapitulace DPH</div>
<div class="inv-table-wrap">
<table class="inv-table" id="vat-table">
<thead>
<tr>
<th style="width:100px">Sazba (%)</th>
<th>Základ</th>
<th>DPH</th>
<th>Celkem</th>
<th class="inv-row-actions"></th>
</tr>
</thead>
<tbody id="vat-body">
${vat.map((br, i) => renderVatRow(br, i)).join("")}
</tbody>
</table>
</div>
</div>` : ""}
<!-- Totals -->
<div class="inv-card">
<div class="inv-card-title">Celkem</div>
<div class="totals-row">
<div class="total-cell">
<span class="total-cell-label">Bez DPH</span>
<input class="inv-input total-cell-input" data-key="total_excluding_vat"
type="text" value="${fmtNum(d.total_excluding_vat)}">
</div>
<div class="total-cell">
<span class="total-cell-label">DPH</span>
<input class="inv-input total-cell-input" data-key="total_vat"
type="text" value="${fmtNum(d.total_vat)}">
</div>
<div class="total-cell primary">
<span class="total-cell-label">K úhradě</span>
<input class="inv-input total-cell-input" data-key="total_including_vat"
type="text" value="${fmtNum(d.total_including_vat)}">
</div>
</div>
</div>
${d.notes ? `
<div class="inv-card">
<div class="inv-card-title">Poznámka</div>
<input class="inv-input" data-key="notes" type="text"
value="${escapeHtmlAttr(d.notes)}">
</div>` : ""}
`;
$("invoice-form").innerHTML = html;
bindFieldHandlers();
}
function renderItemRow(item, i) {
return `
<tr data-row="${i}">
<td><input data-field="description" type="text" value="${escapeHtmlAttr(item.description || "")}"></td>
<td class="num"><input data-field="quantity" type="text" value="${fmtNum(item.quantity)}"></td>
<td><input data-field="unit" type="text" value="${escapeHtmlAttr(item.unit || "")}"></td>
<td class="num"><input data-field="unit_price_excluding_vat" type="text" value="${fmtNum(item.unit_price_excluding_vat)}"></td>
<td class="num"><input data-field="vat_rate" type="text" value="${fmtNum(item.vat_rate)}"></td>
<td class="num"><input data-field="total_excluding_vat" type="text" value="${fmtNum(item.total_excluding_vat)}"></td>
<td class="num"><input data-field="total_including_vat" type="text" value="${fmtNum(item.total_including_vat)}"></td>
<td class="inv-row-actions"><button class="inv-row-delete" type="button" data-action="delete-item" title="Smazat">×</button></td>
</tr>`;
}
function renderVatRow(br, i) {
return `
<tr data-row="${i}">
<td class="num"><input data-field="rate" type="text" value="${fmtNum(br.rate)}"></td>
<td class="num"><input data-field="base" type="text" value="${fmtNum(br.base)}"></td>
<td class="num"><input data-field="vat" type="text" value="${fmtNum(br.vat)}"></td>
<td class="num"><input data-field="total" type="text" value="${fmtNum(br.total)}"></td>
<td class="inv-row-actions"><button class="inv-row-delete" type="button" data-action="delete-vat" title="Smazat">×</button></td>
</tr>`;
}
function bindFieldHandlers() {
// Top-level + nested fields
$("invoice-form").querySelectorAll("input[data-key]").forEach((el) => {
el.addEventListener("input", () => {
const key = el.dataset.key;
const parent = el.dataset.parent;
const val = parseFieldValue(key, el.value);
if (parent) {
if (!state.data[parent]) state.data[parent] = {};
state.data[parent][key] = val;
} else {
state.data[key] = val;
}
el.classList.toggle("is-empty", el.value === "");
});
});
// Item rows
const itemsBody = $("items-body");
if (itemsBody) {
itemsBody.addEventListener("input", (e) => {
const inp = e.target.closest("input");
if (!inp) return;
const tr = inp.closest("tr");
const i = +tr.dataset.row;
const field = inp.dataset.field;
if (!state.data.line_items) state.data.line_items = [];
if (!state.data.line_items[i]) state.data.line_items[i] = {};
const numFields = ["quantity", "unit_price_excluding_vat", "vat_rate",
"total_excluding_vat", "total_including_vat"];
state.data.line_items[i][field] =
numFields.includes(field) ? parseNum(inp.value) : inp.value;
});
itemsBody.addEventListener("click", (e) => {
const btn = e.target.closest('[data-action="delete-item"]');
if (!btn) return;
const tr = btn.closest("tr");
const i = +tr.dataset.row;
state.data.line_items.splice(i, 1);
renderForm();
});
}
const addItem = $("add-item-btn");
if (addItem) {
addItem.addEventListener("click", () => {
if (!state.data.line_items) state.data.line_items = [];
state.data.line_items.push({description: "", quantity: null, unit: "",
unit_price_excluding_vat: null, vat_rate: null,
total_excluding_vat: null, total_including_vat: null});
renderForm();
});
}
// VAT rows
const vatBody = $("vat-body");
if (vatBody) {
vatBody.addEventListener("input", (e) => {
const inp = e.target.closest("input");
if (!inp) return;
const tr = inp.closest("tr");
const i = +tr.dataset.row;
const field = inp.dataset.field;
if (!state.data.vat_breakdown) state.data.vat_breakdown = [];
if (!state.data.vat_breakdown[i]) state.data.vat_breakdown[i] = {};
state.data.vat_breakdown[i][field] = parseNum(inp.value);
});
vatBody.addEventListener("click", (e) => {
const btn = e.target.closest('[data-action="delete-vat"]');
if (!btn) return;
const tr = btn.closest("tr");
const i = +tr.dataset.row;
state.data.vat_breakdown.splice(i, 1);
renderForm();
});
}
}
function parseFieldValue(key, str) {
// Numeric top-level fields
const num = ["total_excluding_vat", "total_vat", "total_including_vat"];
if (num.includes(key)) return parseNum(str);
return str;
}
function parseNum(s) {
if (s === "" || s == null) return null;
const n = parseFloat(String(s).replace(",", ".").replace(/\s/g, ""));
return Number.isFinite(n) ? n : null;
}
function fmtNum(v) {
if (v === null || v === undefined || v === "") return "";
if (typeof v === "number") return String(v);
return String(v);
}
// ── Export ──
$("export-btn").addEventListener("click", async () => {
if (!state.jobId) return;
// Persist edits server-side before downloading
await fetch(`/api/save/${state.jobId}`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({data: state.data}),
}).catch(() => {});
window.location.href = `/api/export/${state.jobId}`;
});
$("restart-btn").addEventListener("click", () => {
state = { jobId: null, data: null };
fileInput.value = "";
$("invoice-form").innerHTML = "";
show("upload");
});
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
function escapeHtmlAttr(s) { return escapeHtml(s); }
})();

View File

@@ -0,0 +1,186 @@
/* Invoice-extractor specific styles */
.processing-sub {
font-size: 13px;
color: var(--text-tertiary);
margin: 8px auto 0;
max-width: 400px;
}
.invoice-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.inv-card {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 18px 20px;
box-shadow: var(--shadow-card);
}
.inv-card-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--primary);
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-default);
}
.inv-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 16px;
}
.inv-grid-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px 16px;
}
@media (max-width: 720px) {
.inv-grid, .inv-grid-3 { grid-template-columns: 1fr; }
}
.inv-field { display: flex; flex-direction: column; gap: 4px; }
.inv-field-wide { grid-column: 1 / -1; }
.inv-label {
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary);
letter-spacing: 0.02em;
}
.inv-input {
width: 100%;
padding: 8px 10px;
background: var(--bg-secondary);
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
transition: border-color .15s, background .15s;
}
.inv-input:focus {
outline: none;
border-color: var(--primary);
background: var(--bg-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
.inv-input.is-empty { color: var(--text-quaternary); font-style: italic; }
.inv-input.is-empty::placeholder { color: var(--text-quaternary); }
/* Tables (line items & vat) */
.inv-table-wrap { overflow-x: auto; }
.inv-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.inv-table th, .inv-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border-default);
text-align: left;
}
.inv-table th {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary);
background: var(--bg-secondary);
}
.inv-table td input {
width: 100%;
background: transparent;
border: 1px solid transparent;
padding: 4px 6px;
border-radius: 4px;
font-size: 13px;
font-family: inherit;
color: var(--text-primary);
}
.inv-table td input:focus {
outline: none;
background: var(--bg-primary);
border-color: var(--primary);
}
.inv-table td.num input { text-align: right; }
.inv-row-actions { width: 36px; text-align: center; }
.inv-row-delete {
background: transparent;
border: none;
color: var(--text-quaternary);
cursor: pointer;
font-size: 16px;
padding: 2px 6px;
border-radius: 4px;
}
.inv-row-delete:hover { color: #dc2626; background: rgba(239,68,68,0.08); }
.inv-table-foot {
display: flex;
justify-content: flex-start;
margin-top: 10px;
}
.btn-add-row {
background: transparent;
border: 1px dashed var(--border-strong);
color: var(--text-tertiary);
font-size: 12px;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
}
.btn-add-row:hover { color: var(--primary); border-color: var(--primary); }
.totals-row {
display: flex;
justify-content: flex-end;
gap: 32px;
padding-top: 14px;
border-top: 2px solid var(--border-default);
}
.totals-row .total-cell {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.totals-row .total-cell-label {
font-size: 11px;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.totals-row .total-cell-input {
width: 140px;
text-align: right;
font-weight: 600;
}
.totals-row .total-cell.primary .total-cell-input {
font-size: 18px;
color: var(--primary);
}
/* 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; }
}

View File

@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Extrakce faktur | 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">Extrakce faktur</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">
<section id="s-upload">
<div class="section-intro">
<h1 class="section-title">Extrakce dat z faktury</h1>
<p class="section-desc">
Nahrajte fakturu (PDF nebo scan) a získáte strukturovaná data
připravená pro účetní systém. Po extrakci můžete data ručně upravit
a stáhnout jako Excel.
</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 fakturu 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">PDF · JPG · PNG · WEBP</p>
<input type="file" id="file-input" accept=".pdf,.jpg,.jpeg,.png,.webp" style="display:none">
</div>
</section>
<section id="s-processing" class="hidden">
<div class="processing-card">
<div class="spinner"></div>
<h2 class="processing-title">Čtu fakturu…</h2>
<p class="processing-sub">AI extrahuje data z obrázku. Obvykle 1030 sekund.</p>
</div>
</section>
<section id="s-result" class="hidden">
<div class="results-header">
<div>
<h2 class="results-title">Extrahovaná data</h2>
<p class="results-meta">Zkontrolujte hodnoty, případně upravte, a stáhněte Excel.</p>
</div>
<div class="results-actions">
<button class="btn btn-secondary" id="restart-btn" type="button">Nová faktura</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>
Stáhnout Excel
</button>
</div>
</div>
<div class="invoice-form" id="invoice-form"></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; }

8
landing/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
.next
.git
.env
.env.local
Dockerfile
.dockerignore
README.md

41
landing/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
landing/AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
landing/CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

32
landing/Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM node:23-alpine AS base
RUN corepack enable
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3010
ENV HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3010
CMD ["node", "server.js"]

36
landing/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

18
landing/eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
landing/next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

35
landing/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "landing",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@auth/core": "^0.34.3",
"@heroicons/react": "^2.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.38.0",
"lucide-react": "^1.14.0",
"next": "16.2.4",
"next-auth": "5.0.0-beta.31",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"tailwindcss": "^4",
"typescript": "^5"
}
}

4282
landing/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
landing/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
landing/public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
landing/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

BIN
landing/src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

155
landing/src/app/globals.css Normal file
View File

@@ -0,0 +1,155 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
/* Dify / Untitled UI palette */
--color-bg-primary: #ffffff;
--color-bg-secondary: #f9fafb;
--color-bg-tertiary: #f2f4f7;
--color-bg-section: #f6f7f9;
--color-text-primary: #101828;
--color-text-secondary: #354052;
--color-text-tertiary: #676f83;
--color-text-quaternary: #98a2b2;
--color-text-disabled: #d0d5dc;
--color-border-subtle: rgb(16 24 40 / 0.04);
--color-border-default: rgb(16 24 40 / 0.08);
--color-border-hover: rgb(16 24 40 / 0.14);
--color-border-strong: #d0d5dc;
--color-card: #ffffff;
--color-card-hover: #f9fafb;
/* Dify primary blue */
--color-primary-50: #eff4ff;
--color-primary-100: #d1e0ff;
--color-primary-200: #b2caff;
--color-primary-500: #2970ff;
--color-primary-600: #155aef;
--color-primary-700: #004aeb;
--color-primary-800: #00359e;
--color-primary: var(--color-primary-600);
--color-primary-hover: var(--color-primary-700);
--color-primary-foreground: #ffffff;
/* Accent palette for tile icons (also re-declared in :root below so
Tailwind v4 doesn't tree-shake them — they're only used via inline
style in tile.tsx, which Tailwind's purger can't see). */
--color-accent-blue: #155aef;
--color-accent-indigo: #444ce7;
--color-accent-violet: #7a5af8;
--color-accent-cyan: #0ba5ec;
--color-accent-emerald: #17b26a;
--color-accent-amber: #f79009;
--color-accent-rose: #f04438;
--color-accent-gray: #475467;
--color-destructive: #d92d20;
--color-destructive-bg: #fef3f2;
--color-destructive-border: #fda29b;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--shadow-card: 0 1px 2px rgb(16 24 40 / 0.05);
--shadow-card-hover: 0 12px 24px -8px rgb(16 24 40 / 0.12), 0 4px 8px -4px rgb(16 24 40 / 0.06);
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace;
}
/* Re-declare accents in :root so they survive Tailwind v4's purge of unused
@theme variables. Used by inline styles in tile.tsx (color-mix). */
:root {
--color-accent-blue: #155aef;
--color-accent-indigo: #444ce7;
--color-accent-violet: #7a5af8;
--color-accent-cyan: #0ba5ec;
--color-accent-emerald: #17b26a;
--color-accent-amber: #f79009;
--color-accent-rose: #f04438;
--color-accent-gray: #475467;
}
.dark {
--color-bg-primary: #14181f;
--color-bg-secondary: #1a1f29;
--color-bg-tertiary: #232936;
--color-bg-section: #181d26;
--color-text-primary: #f5f7fa;
--color-text-secondary: #c8ccd5;
--color-text-tertiary: #98a2b2;
--color-text-quaternary: #676f83;
--color-text-disabled: #4a525e;
--color-border-subtle: rgb(255 255 255 / 0.05);
--color-border-default: rgb(255 255 255 / 0.08);
--color-border-hover: rgb(255 255 255 / 0.14);
--color-border-strong: #354052;
--color-card: #1a1f29;
--color-card-hover: #232936;
--color-primary: var(--color-primary-500);
--color-primary-hover: #4187ff;
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.4);
--shadow-card-hover: 0 12px 24px -8px rgb(0 0 0 / 0.5), 0 4px 8px -4px rgb(0 0 0 / 0.3);
}
html,
body {
height: 100%;
}
body {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Dify-style typography utilities */
.system-2xs-medium-uppercase {
font-size: 10px;
line-height: 14px;
font-weight: 500;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.system-xs-regular {
font-size: 12px;
line-height: 16px;
font-weight: 400;
}
.system-xs-medium {
font-size: 12px;
line-height: 16px;
font-weight: 500;
}
.system-sm-medium {
font-size: 14px;
line-height: 20px;
font-weight: 500;
}
.system-sm-semibold {
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
/* Scrollbar polish */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
scrollbar-width: none;
}

View File

@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin", "latin-ext"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin", "latin-ext"],
});
export const metadata: Metadata = {
title: "Colsys AI",
description: "Interní AI portál společnosti Colsys.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="cs"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
suppressHydrationWarning
>
<body className="min-h-full flex flex-col">
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,114 @@
import { redirect } from "next/navigation";
import { auth, signIn } from "@/auth";
import { Brand } from "@/components/brand";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type SearchParams = Promise<{ callbackUrl?: string; error?: string }>;
export default async function LoginPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const { callbackUrl, error } = await searchParams;
const session = await auth();
if (session?.user) {
redirect(callbackUrl || "/");
}
const hasEntra =
!!process.env.AUTH_MICROSOFT_ENTRA_ID_ID &&
!!process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET;
const hasDevPassword = !!process.env.DEV_PORTAL_PASSWORD;
return (
<div className="flex min-h-full flex-col bg-[var(--color-bg-secondary)]">
<div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-sm">
<div className="mb-6 flex justify-center">
<Brand size="lg" />
</div>
<div className="rounded-xl border-[0.5px] border-[var(--color-border-default)] bg-[var(--color-card)] p-7 shadow-[var(--shadow-card)]">
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
Přihlášení
</h1>
<p className="system-xs-regular mt-1 text-[var(--color-text-tertiary)]">
Pokračujte přihlášením přes firemní účet.
</p>
{error ? (
<div className="mt-5 rounded-lg border-[0.5px] border-[var(--color-destructive-border)] bg-[var(--color-destructive-bg)] px-3 py-2.5 text-xs text-[var(--color-destructive)]">
Přihlášení se nezdařilo. Zkuste to prosím znovu.
</div>
) : null}
{hasEntra ? (
<form
className="mt-6"
action={async () => {
"use server";
await signIn("microsoft-entra-id", {
redirectTo: callbackUrl || "/",
});
}}
>
<Button type="submit" className="w-full" size="lg">
Pokračovat přes Microsoft
</Button>
</form>
) : null}
{hasEntra && hasDevPassword ? (
<div className="my-6 flex items-center gap-3 text-xs text-[var(--color-text-quaternary)]">
<span className="h-px flex-1 bg-[var(--color-border-default)]" />
nebo
<span className="h-px flex-1 bg-[var(--color-border-default)]" />
</div>
) : null}
{hasDevPassword ? (
<form
className="mt-6 flex flex-col gap-3"
action={async (formData: FormData) => {
"use server";
await signIn("dev", {
password: formData.get("password"),
redirectTo: callbackUrl || "/",
});
}}
>
<label className="system-xs-medium text-[var(--color-text-secondary)]">
Vývojový přístup
</label>
<Input
name="password"
type="password"
placeholder="Přístupové heslo"
required
autoFocus
/>
<Button type="submit" variant="secondary" size="lg">
Přihlásit se heslem
</Button>
</form>
) : null}
{!hasEntra && !hasDevPassword ? (
<p className="mt-6 rounded-lg bg-[var(--color-bg-tertiary)] p-4 text-sm text-[var(--color-text-tertiary)]">
Není nakonfigurován žádný poskytovatel přihlášení. Nastavte{" "}
<code className="font-mono text-xs">DEV_PORTAL_PASSWORD</code>{" "}
nebo Microsoft Entra v prostředí.
</p>
) : null}
</div>
<p className="mt-6 text-center text-xs text-[var(--color-text-quaternary)]">
Interní AI portál společnosti Colsys
</p>
</div>
</div>
</div>
);
}

33
landing/src/app/page.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { Header } from "@/components/header";
import { TileGrid } from "@/components/tile-grid";
import apps from "@/data/apps.json";
import type { AppTile } from "@/components/tile";
export default function Home() {
return (
<div className="flex min-h-full flex-col bg-[var(--color-bg-secondary)]">
<Header />
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-7 px-4 py-8 sm:px-8 sm:py-10">
<section className="flex flex-col gap-2">
<span className="system-2xs-medium-uppercase text-[var(--color-primary)]">
Interní AI portál
</span>
<h1 className="text-2xl font-semibold tracking-tight text-[var(--color-text-primary)] sm:text-3xl">
Vaše AI nástroje na jednom místě
</h1>
<p className="max-w-2xl text-sm text-[var(--color-text-tertiary)] sm:text-base">
Vyberte si průvodce úlohou, nebo zahajte volný chat. Vše běží na
firemních klíčích s rozpočty, sledováním nákladů a bez exportu
vašich dat ven.
</p>
</section>
<TileGrid apps={apps as AppTile[]} />
</main>
<footer className="mx-auto w-full max-w-7xl px-4 pb-10 pt-2 text-xs text-[var(--color-text-quaternary)] sm:px-8">
Nevkládejte do AI nástrojů hesla ani neredaktované osobní či citlivé
údaje.
</footer>
</div>
);
}

51
landing/src/auth.ts Normal file
View File

@@ -0,0 +1,51 @@
import NextAuth, { type NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
const providers: NextAuthConfig["providers"] = [];
if (
process.env.AUTH_MICROSOFT_ENTRA_ID_ID &&
process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET
) {
providers.push(
MicrosoftEntraID({
clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID,
clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET,
issuer: process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER,
}),
);
}
if (process.env.DEV_PORTAL_PASSWORD) {
providers.push(
Credentials({
id: "dev",
name: "Development access",
credentials: {
password: { label: "Access password", type: "password" },
},
authorize: async (credentials) => {
const supplied =
typeof credentials?.password === "string" ? credentials.password : "";
if (supplied && supplied === process.env.DEV_PORTAL_PASSWORD) {
return {
id: "dev-user",
name: "Developer",
email: "dev@portal.local",
};
}
return null;
},
}),
);
}
export const { handlers, auth, signIn, signOut } = NextAuth({
providers,
pages: {
signIn: "/login",
},
session: { strategy: "jwt" },
trustHost: true,
});

Some files were not shown because too many files have changed in this diff Show More