Initial portal commit: landing + 9 AI-powered apps

Apps:
- dwg-rooms: extract room numbers from DWG/DXF
- dwg-counting: count symbols in PDF drawings (OpenCV template matching)
- contract-check: review PDF contracts against a checklist (Claude vision + Tesseract OCR fallback)
- email-drafter: bullet notes → polished Czech/English business emails
- invoice-extractor: PDF/image invoice → structured data → Excel
- translator: Czech-first translator across 19 languages with tone control
- vv-check: find inconsistent unit prices across VV sheets in one workbook
- vv-compare: diff original vs new VV files (changes / added / removed)
- feature-request: portal users submit ideas + sample files

Infrastructure:
- LiteLLM gateway with per-app virtual keys + budgets
- Langfuse observability
- Geist font, shared theme, cross-subdomain back link + theme sync via cookie/URL
- Caddy reverse proxy on *.klas.chat
This commit is contained in:
Ondřej Glaser
2026-05-13 15:25:04 +02:00
commit 48cef99257
139 changed files with 20171 additions and 0 deletions

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

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