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:
26
.env.example
Normal file
26
.env.example
Normal 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
53
.gitignore
vendored
Normal 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
339
AI_PORTAL_HANDOFF.md
Normal 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
78
README.md
Normal 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.
|
||||
5
contract-check/.env.example
Normal file
5
contract-check/.env.example
Normal 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
23
contract-check/Dockerfile
Normal 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
150
contract-check/analyzer.py
Normal 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
|
||||
98
contract-check/checklist.py
Normal file
98
contract-check/checklist.py
Normal 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,
|
||||
},
|
||||
]
|
||||
24
contract-check/docker-compose.yml
Normal file
24
contract-check/docker-compose.yml
Normal 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
134
contract-check/main.py
Normal 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")
|
||||
257
contract-check/pdf_annotator.py
Normal file
257
contract-check/pdf_annotator.py
Normal 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
|
||||
9
contract-check/requirements.txt
Normal file
9
contract-check/requirements.txt
Normal 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
|
||||
264
contract-check/static/app.js
Normal file
264
contract-check/static/app.js
Normal 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) =>
|
||||
({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
|
||||
loadChecklist();
|
||||
})();
|
||||
341
contract-check/static/extra.css
Normal file
341
contract-check/static/extra.css
Normal 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; }
|
||||
}
|
||||
148
contract-check/static/index.html
Normal file
148
contract-check/static/index.html
Normal 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 30–90 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>
|
||||
461
contract-check/static/styles.css
Normal file
461
contract-check/static/styles.css
Normal 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; }
|
||||
170
dify-workflows/shrnuti-dokumentu.yml
Normal file
170
dify-workflows/shrnuti-dokumentu.yml
Normal 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
60
docker-compose.yml
Normal 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
|
||||
3
dwg-counting/.env.example
Normal file
3
dwg-counting/.env.example
Normal 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
47
dwg-counting/Dockerfile
Normal 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
286
dwg-counting/counting.py
Normal 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}
|
||||
24
dwg-counting/docker-compose.yml
Normal file
24
dwg-counting/docker-compose.yml
Normal 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
|
||||
41
dwg-counting/excel_export.py
Normal file
41
dwg-counting/excel_export.py
Normal 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)
|
||||
BIN
dwg-counting/libredwg-0.12.5.tar.xz
Normal file
BIN
dwg-counting/libredwg-0.12.5.tar.xz
Normal file
Binary file not shown.
440
dwg-counting/main.py
Normal file
440
dwg-counting/main.py
Normal 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")
|
||||
93
dwg-counting/pdf_export.py
Normal file
93
dwg-counting/pdf_export.py
Normal 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
351
dwg-counting/renderer.py
Normal 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
|
||||
16
dwg-counting/requirements.txt
Normal file
16
dwg-counting/requirements.txt
Normal 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
442
dwg-counting/static/app.js
Normal 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 deduplikaci.
|
||||
</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) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
})();
|
||||
301
dwg-counting/static/extra.css
Normal file
301
dwg-counting/static/extra.css
Normal 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);
|
||||
}
|
||||
205
dwg-counting/static/index.html
Normal file
205
dwg-counting/static/index.html
Normal 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 20–60 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>
|
||||
461
dwg-counting/static/styles.css
Normal file
461
dwg-counting/static/styles.css
Normal 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
121
dwg-counting/vision.py
Normal 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
6
dwg-rooms/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
libredwg-0.12.5/
|
||||
8
dwg-rooms/.env.example
Normal file
8
dwg-rooms/.env.example
Normal 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
38
dwg-rooms/Dockerfile
Normal 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"]
|
||||
24
dwg-rooms/docker-compose.yml
Normal file
24
dwg-rooms/docker-compose.yml
Normal 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
48
dwg-rooms/excel_export.py
Normal 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
168
dwg-rooms/extractor.py
Normal 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", "m²", "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
|
||||
BIN
dwg-rooms/libredwg-0.12.5.tar.xz
Normal file
BIN
dwg-rooms/libredwg-0.12.5.tar.xz
Normal file
Binary file not shown.
80
dwg-rooms/llm_helper.py
Normal file
80
dwg-rooms/llm_helper.py
Normal 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
143
dwg-rooms/main.py
Normal 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")
|
||||
9
dwg-rooms/requirements.txt
Normal file
9
dwg-rooms/requirements.txt
Normal 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
209
dwg-rooms/static/app.js
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────
|
||||
loadDefaults();
|
||||
161
dwg-rooms/static/index.html
Normal file
161
dwg-rooms/static/index.html
Normal 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 · .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
459
dwg-rooms/static/styles.css
Normal 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; }
|
||||
3
email-drafter/.env.example
Normal file
3
email-drafter/.env.example
Normal 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
12
email-drafter/Dockerfile
Normal 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"]
|
||||
19
email-drafter/docker-compose.yml
Normal file
19
email-drafter/docker-compose.yml
Normal 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
150
email-drafter/main.py
Normal 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")
|
||||
5
email-drafter/requirements.txt
Normal file
5
email-drafter/requirements.txt
Normal 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
120
email-drafter/static/app.js
Normal 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);
|
||||
});
|
||||
})();
|
||||
201
email-drafter/static/extra.css
Normal file
201
email-drafter/static/extra.css
Normal 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; }
|
||||
}
|
||||
181
email-drafter/static/index.html
Normal file
181
email-drafter/static/index.html
Normal 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 5–15 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>
|
||||
461
email-drafter/static/styles.css
Normal file
461
email-drafter/static/styles.css
Normal 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; }
|
||||
14
feature-request/Dockerfile
Normal file
14
feature-request/Dockerfile
Normal 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"]
|
||||
18
feature-request/docker-compose.yml
Normal file
18
feature-request/docker-compose.yml
Normal 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
94
feature-request/main.py
Normal 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")
|
||||
3
feature-request/requirements.txt
Normal file
3
feature-request/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
python-multipart>=0.0.9
|
||||
95
feature-request/static/app.js
Normal file
95
feature-request/static/app.js
Normal 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) =>
|
||||
({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
})();
|
||||
164
feature-request/static/extra.css
Normal file
164
feature-request/static/extra.css
Normal 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; }
|
||||
}
|
||||
151
feature-request/static/index.html
Normal file
151
feature-request/static/index.html
Normal 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>
|
||||
461
feature-request/static/styles.css
Normal file
461
feature-request/static/styles.css
Normal 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; }
|
||||
89
ideas/Instrukce_projektu_kontrola_položek_VV.md
Normal file
89
ideas/Instrukce_projektu_kontrola_položek_VV.md
Normal 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ů.
|
||||
|
||||
83
ideas/Instrukce_projektu_změna_VV (1).md
Normal file
83
ideas/Instrukce_projektu_změna_VV (1).md
Normal 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 1–5: 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.
|
||||
3
invoice-extractor/.env.example
Normal file
3
invoice-extractor/.env.example
Normal 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
|
||||
14
invoice-extractor/Dockerfile
Normal file
14
invoice-extractor/Dockerfile
Normal 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"]
|
||||
24
invoice-extractor/docker-compose.yml
Normal file
24
invoice-extractor/docker-compose.yml
Normal 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
|
||||
154
invoice-extractor/excel_export.py
Normal file
154
invoice-extractor/excel_export.py
Normal 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
|
||||
190
invoice-extractor/extractor.py
Normal file
190
invoice-extractor/extractor.py
Normal 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
98
invoice-extractor/main.py
Normal 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")
|
||||
8
invoice-extractor/requirements.txt
Normal file
8
invoice-extractor/requirements.txt
Normal 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
|
||||
352
invoice-extractor/static/app.js
Normal file
352
invoice-extractor/static/app.js
Normal 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) =>
|
||||
({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
function escapeHtmlAttr(s) { return escapeHtml(s); }
|
||||
})();
|
||||
186
invoice-extractor/static/extra.css
Normal file
186
invoice-extractor/static/extra.css
Normal 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; }
|
||||
}
|
||||
106
invoice-extractor/static/index.html
Normal file
106
invoice-extractor/static/index.html
Normal 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 10–30 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>
|
||||
461
invoice-extractor/static/styles.css
Normal file
461
invoice-extractor/static/styles.css
Normal 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
8
landing/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
README.md
|
||||
41
landing/.gitignore
vendored
Normal file
41
landing/.gitignore
vendored
Normal 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
5
landing/AGENTS.md
Normal 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
1
landing/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
32
landing/Dockerfile
Normal file
32
landing/Dockerfile
Normal 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
36
landing/README.md
Normal 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
18
landing/eslint.config.mjs
Normal 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
7
landing/next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
35
landing/package.json
Normal file
35
landing/package.json
Normal 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
4282
landing/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
landing/pnpm-workspace.yaml
Normal file
3
landing/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
7
landing/postcss.config.mjs
Normal file
7
landing/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
landing/public/file.svg
Normal file
1
landing/public/file.svg
Normal 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
1
landing/public/globe.svg
Normal 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
1
landing/public/next.svg
Normal 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 |
1
landing/public/vercel.svg
Normal file
1
landing/public/vercel.svg
Normal 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 |
1
landing/public/window.svg
Normal file
1
landing/public/window.svg
Normal 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 |
3
landing/src/app/api/auth/[...nextauth]/route.ts
Normal file
3
landing/src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
BIN
landing/src/app/favicon.ico
Normal file
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
155
landing/src/app/globals.css
Normal 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;
|
||||
}
|
||||
37
landing/src/app/layout.tsx
Normal file
37
landing/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
landing/src/app/login/page.tsx
Normal file
114
landing/src/app/login/page.tsx
Normal 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
33
landing/src/app/page.tsx
Normal 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
51
landing/src/auth.ts
Normal 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
Reference in New Issue
Block a user