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:
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")
|
||||
Reference in New Issue
Block a user