# Offene Punkte — bei jedem Start prüfen und abhaken

Keine offenen Punkte.

jq -Rs '{systemMessage: ., hookSpecificOutput:{hookEventName:"SessionStart", additionalContext: .}}' /home/marcuh/claude-projects/08-07/OFFENE-PUNKTE.md


# Offene Punkte — bei jedem Start prüfen und abhaken

Keine offenen Punkte.


<local-command-caveat>Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.</local-command-caveat>


<command-name>/clear</command-name>
            <command-message>clear</command-message>
            <command-args></command-args>


<local-command-stdout></local-command-stdout>


Was weißt du noch über das Projekt?


CronCreate CronDelete CronList DesignSync EnterPlanMode EnterWorktree ExitPlanMode ExitWorktree Monitor NotebookEdit PushNotification RemoteTrigger SendMessage TaskCreate TaskGet TaskList TaskOutput TaskStop TaskUpdate WebFetch WebSearch


claude claude-code-guide Explore general-purpose Plan statusline-setup

- claude: Catch-all for any task that doesn't fit a more specific agent. FleetView's default when no agent name is typed. (Tools: *)

- claude-code-guide: Use this agent when the user asks questions ("Can Claude...", "Does Claude...", "How do I...") about: (1) Claude Code (the CLI tool) - features, hooks, slash commands, MCP servers, settings, IDE integrations, keyboard shortcuts; (2) Claude Agent SDK - building custom agents; (3) Claude API (formerly Anthropic API) - API usage, tool use, Anthropic SDK usage. **IMPORTANT:** Before spawning a new agent, check if there is already a running or recently completed claude-code-guide agent that you can continue via SendMessage. (Tools: Bash, Read, WebFetch, WebSearch)

- Explore: Read-only search agent for broad fan-out searches — when answering means sweeping many files, directories, or naming conventions and you only need the conclusion, not the file dumps. It reads excerpts rather than whole files, so it locates code; it doesn't review or audit it. Specify search breadth: "medium" for moderate exploration, "very thorough" for multiple locations and naming conventions. (Tools: All tools except Agent, Artifact, ExitPlanMode, Edit, Write, NotebookEdit)

- general-purpose: General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you. (Tools: *)

- Plan: Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs. (Tools: All tools except Agent, Artifact, ExitPlanMode, Edit, Write, NotebookEdit)

- statusline-setup: Use this agent to configure the user's Claude Code status line setting. (Tools: Read, Edit)


- git-sync: Commit + Push aller persistent konfigurierten Projekt-Repos
- deep-research: Deep research harness — fan-out web searches, fetch sources, adversarially verify claims, synthesize a cited report. - When the user wants a deep, multi-source, fact-checked research report on any topic. BEFORE invoking, check if the question is specific enough to research directly — if underspecified (e.g., "what car to buy" without budget/use-case/region), ask 2-3 clarifying questions to narrow scope. Then pass the refined question as args, weaving the answers in.
- dataviz: Use this skill whenever you are about to create ANY chart, graph, plot, dashboard, or data visualization, in ANY output medium — an HTML or React artifact, inline SVG, plotting code in any library (matplotlib, plotly, d3, Recharts, …), an image/PNG you will render and upload, or a chart shared into Slack. Read it BEFORE writing the first line of chart code, choosing chart colors, building a stat tile / meter / KPI row, or laying out a dashboard. Produces visualizations that read as one system — elegant, accessible, consistent in light and dark — using a brand-neutral placeholder palette you swap for your own. Teaches a design-system-agnostic method: a form heuristic, a color formula with a runnable validator, mark specs, and interaction rules. A validated default palette is documented in `references/palette.md` — swap that file's values for your brand's. Triggers on: "chart", "graph", "plot", "data viz", "visualization", "dashboard", "analytics", "visualize data", "categorical colors", "sequential / diverging palette", "stat tile", "sparkline", "heatmap", "legend", "axis", "tooltip", "chart colors", "color by series".
- artifact-design: Design guidance and fundamentals for Artifacts.
- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, suggest the /config command.
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".
- verify: Verify that a code change actually does what it's supposed to by exercising it end-to-end and observing behavior — drive the affected flow, not just tests or typecheck. Run before committing nontrivial changes; bootstraps this repo's project verify skill if none exists yet. Don't invoke it on a diff that only touches tests, docs, or other code with no runtime surface to drive (a change to product source always has one) — there's nothing to observe.
- code-review: Review the current diff for correctness bugs and reuse/simplification/efficiency cleanups at the given effort level (low/medium: fewer, high-confidence findings; high→max: broader coverage, may include uncertain findings; ultra: deep multi-agent review in the cloud). Pass --comment to post findings as inline PR comments, or --fix to apply the findings to the working tree after the review.
- simplify: Review the changed code for reuse, simplification, efficiency, and altitude cleanups, then apply the fixes. Quality only — it does not hunt for bugs; use /code-review for that.
- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.
- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.
- schedule: Create, update, list, or run scheduled cloud agents (routines) that execute on a cron schedule. - When the user wants to schedule a recurring cloud agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/routines. Also use when the user wants a one-time scheduled run ("run this once at 3pm", "remind me to check X tomorrow").
- claude-api: Reference for the Claude API / Anthropic SDK — model ids, pricing, params, streaming, tool use, MCP, agents, caching, token counting, model migration.
TRIGGER — read BEFORE opening the target file; don't skip because it "looks like a one-liner" — whenever: the prompt names Claude/Anthropic in any form (Claude, Anthropic, Fable, Opus, Sonnet, Haiku, `anthropic`, `@anthropic-ai`, `claude-*`, `us.anthropic.*`, `[1m]`); the user asks about an LLM (pricing/model choice/limits/caching) — never answer from memory; OR the task is LLM-shaped with provider unstated (agent/MCP/tool-definition/multi-agent/RAG/LLM-judge/computer-use; generate/summarize/extract/classify/rewrite/converse over NL; debugging refusals/cutoffs/streaming/tool-calls/tokens).
SKIP only when another provider is being worked on (overrides all triggers): OpenAI/GPT/Gemini/Llama/Mistral/Cohere/Ollama named in the query; OR `grep -rE 'openai|langchain_openai|google.generativeai|genai|mistralai|cohere|ollama'` over the project hits (run this grep FIRST if no provider named — don't Read the file).
- run: Launch and drive this project's app to see a change working. Use when asked to run, start, or screenshot the app, or to confirm a change works in the real app (not just tests). First looks for a project skill that already covers launching the app; otherwise falls back to built-in patterns per project type (CLI, server, TUI, Electron, browser-driven, library).
- init: Initialize a new CLAUDE.md file with codebase documentation
- review: Review a GitHub pull request; for your working diff use /code-review
- security-review: Complete a security review of the pending changes on the current branch

git-sync deep-research dataviz artifact-design update-config keybindings-help verify code-review simplify fewer-permission-prompts loop schedule claude-api run init review security-review


Aus dem Memory zum Projekt 08-07 (Würth KI-Demo):

- **Ziel**: KI-Demo für Würth, zwei Richtungen — Preis-/Margen-Assistent mit Chat und Cross-Sell.
- **Tech-Stack**: Tauri (Rust-Backend + Vue) auf SQLite, LLM-Calls im Rust-Kern.
- **Datenbasis**: Plato-Export `extracted_xml_data.json`, 191 Aufträge (Ziel ~2000) — reicht für die Demo, nicht für statistisches Cross-Sell.
- **Datenmodell**: SQLite-Tabellen `auftraege`, `positionen`, `module`, `kunden` als Arbeitsschicht.
- **Datenveredelung**: Offline-Anreicherung — Branche, Geo, Dedup, Modul-Klartext; als eigener Use Case positioniert.
- **Fahrzeug-Veredelung**: Fahrzeugtyp steckt nur in den .plax-Rohdaten (PDF + Freitext), per LLM extrahiert nach `auftrag_fzg`; 134/191 erkannt, fast alles Vans.
- **Stückliste-Pipeline**: Bauteil-Klartext + Artikelnummern aus den PDFs in den .plax, wiederholbarer Extract nach `module.klartext`, Popup je Auftrag.
- **AI-Assistent**: Tab „Fragen" — Text→SQL→Auto-Visualisierung via Claude (Sonnet 5), API-Key in `~/.ssh/anthropic_api_key`.

Feedback-Notiz: Prototyp, nicht über Datenmengen reden, knapp bleiben.


ok -- kannst due eine DrawIo bauen, die die 3 UseCses für KI darstellt?
-> KI zur Analyse und Aufbau des Datenbestandes (Nutzung als Tool)
-> KI zur Analyse -> der FragenTab (ersetzt aufwändiges starres repoerting)
--> KI mit eigenem LLM --> Vorschlagsengine


Write

<mxfile host="app.diagrams.net" modified="2026-07-06T00:00:00.000Z" agent="drawio" version="24.0.0" type="device">
  <diagram name="KI Use Cases" id="ki-usecases-1">
    <mxGraphModel dx="1400" dy="850" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="826" math="0" shadow="0">
      <root>
        <mxCell id="0" />
        <mxCell id="1" parent="0" />

        <mxCell id="title" value="KI-Einsatz — drei Use Cases" style="text;html=1;fontSize=20;fontStyle=1;align=left;verticalAlign=middle;" vertex="1" parent="1">
          <mxGeometry x="40" y="20" width="600" height="30" as="geometry" />
        </mxCell>

        <!-- ================= Use Case 1 ================= -->
        <mxCell id="uc1" value="1 · KI als Tool — Analyse & Aufbau des Datenbestandes" style="swimlane;horizontal=1;startSize=30;rounded=1;fillColor=#fafafa;strokeColor=#666666;fontSize=14;fontStyle=1;swimlaneFillColor=#ffffff;" vertex="1" parent="1">
          <mxGeometry x="40" y="70" width="1090" height="190" as="geometry" />
        </mxCell>
        <mxCell id="uc1-src" value="Rohdaten
.plax (PDF + Freitext)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="uc1">
          <mxGeometry x="40" y="60" width="240" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc1-llm" value="LLM-Extraktion
Fahrzeugtyp · Stückliste · Branche · Geo · Dedup" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;fontStyle=1;" vertex="1" parent="uc1">
          <mxGeometry x="400" y="60" width="290" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc1-db" value="SQLite-Datenbestand
auftraege · positionen · module · kunden" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="uc1">
          <mxGeometry x="810" y="60" width="240" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc1-note" value="Batch, offline, wiederholbar" style="text;html=1;fontSize=11;fontStyle=2;align=center;fontColor=#666666;" vertex="1" parent="uc1">
          <mxGeometry x="400" y="135" width="290" height="20" as="geometry" />
        </mxCell>
        <mxCell id="uc1-e1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;" edge="1" parent="uc1" source="uc1-src" target="uc1-llm">
          <mxGeometry relative="1" as="geometry" />
        </mxCell>
        <mxCell id="uc1-e2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;" edge="1" parent="uc1" source="uc1-llm" target="uc1-db">
          <mxGeometry relative="1" as="geometry" />
        </mxCell>

        <!-- ================= Use Case 2 ================= -->
        <mxCell id="uc2" value="2 · KI zur Analyse — Fragen-Tab (ersetzt starres Reporting)" style="swimlane;horizontal=1;startSize=30;rounded=1;fillColor=#fafafa;strokeColor=#666666;fontSize=14;fontStyle=1;swimlaneFillColor=#ffffff;" vertex="1" parent="1">
          <mxGeometry x="40" y="290" width="1090" height="190" as="geometry" />
        </mxCell>
        <mxCell id="uc2-q" value="Frage in
natürlicher Sprache" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="uc2">
          <mxGeometry x="40" y="60" width="200" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc2-llm" value="LLM
Text → SQL" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;fontStyle=1;" vertex="1" parent="uc2">
          <mxGeometry x="310" y="60" width="200" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc2-db" value="SQLite
(Datenbestand aus UC 1)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="uc2">
          <mxGeometry x="580" y="60" width="200" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc2-viz" value="Auto-Visualisierung
Chart / Tabelle / Antwort" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=12;" vertex="1" parent="uc2">
          <mxGeometry x="850" y="60" width="200" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc2-note" value="Ad-hoc statt vordefinierter Reports" style="text;html=1;fontSize=11;fontStyle=2;align=center;fontColor=#666666;" vertex="1" parent="uc2">
          <mxGeometry x="310" y="135" width="470" height="20" as="geometry" />
        </mxCell>
        <mxCell id="uc2-e1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;" edge="1" parent="uc2" source="uc2-q" target="uc2-llm">
          <mxGeometry relative="1" as="geometry" />
        </mxCell>
        <mxCell id="uc2-e2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;" edge="1" parent="uc2" source="uc2-llm" target="uc2-db">
          <mxGeometry relative="1" as="geometry" />
        </mxCell>
        <mxCell id="uc2-e3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;" edge="1" parent="uc2" source="uc2-db" target="uc2-viz">
          <mxGeometry relative="1" as="geometry" />
        </mxCell>

        <!-- ================= Use Case 3 ================= -->
        <mxCell id="uc3" value="3 · KI mit eigenem LLM — Vorschlagsengine" style="swimlane;horizontal=1;startSize=30;rounded=1;fillColor=#fafafa;strokeColor=#666666;fontSize=14;fontStyle=1;swimlaneFillColor=#ffffff;" vertex="1" parent="1">
          <mxGeometry x="40" y="510" width="1090" height="190" as="geometry" />
        </mxCell>
        <mxCell id="uc3-src" value="Auftrags- & Kundenhistorie
(veredelter Datenbestand)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="uc3">
          <mxGeometry x="40" y="60" width="240" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc3-llm" value="Eigenes LLM
auf Bestandsdaten" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=12;fontStyle=1;" vertex="1" parent="uc3">
          <mxGeometry x="400" y="60" width="290" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc3-out" value="Vorschläge
Cross-Sell · Konfiguration" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=12;" vertex="1" parent="uc3">
          <mxGeometry x="810" y="60" width="240" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc3-e1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;" edge="1" parent="uc3" source="uc3-src" target="uc3-llm">
          <mxGeometry relative="1" as="geometry" />
        </mxCell>
        <mxCell id="uc3-e2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;" edge="1" parent="uc3" source="uc3-llm" target="uc3-out">
          <mxGeometry relative="1" as="geometry" />
        </mxCell>

      </root>
    </mxGraphModel>
  </diagram>
</mxfile>


File created successfully at: /home/marcuh/projects/08-07/ki-usecases.drawio (file state is current in your context — no need to Read it back)

<mxfile host="app.diagrams.net" modified="2026-07-06T00:00:00.000Z" agent="drawio" version="24.0.0" type="device">
  <diagram name="KI Use Cases" id="ki-usecases-1">
    <mxGraphModel dx="1400" dy="850" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="826" math="0" shadow="0">
      <root>
        <mxCell id="0" />
        <mxCell id="1" parent="0" />

        <mxCell id="title" value="KI-Einsatz — drei Use Cases" style="text;html=1;fontSize=20;fontStyle=1;align=left;verticalAlign=middle;" vertex="1" parent="1">
          <mxGeometry x="40" y="20" width="600" height="30" as="geometry" />
        </mxCell>

        <!-- ================= Use Case 1 ================= -->
        <mxCell id="uc1" value="1 · KI als Tool — Analyse & Aufbau des Datenbestandes" style="swimlane;horizontal=1;startSize=30;rounded=1;fillColor=#fafafa;strokeColor=#666666;fontSize=14;fontStyle=1;swimlaneFillColor=#ffffff;" vertex="1" parent="1">
          <mxGeometry x="40" y="70" width="1090" height="190" as="geometry" />
        </mxCell>
        <mxCell id="uc1-src" value="Rohdaten
.plax (PDF + Freitext)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="uc1">
          <mxGeometry x="40" y="60" width="240" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc1-llm" value="LLM-Extraktion
Fahrzeugtyp · Stückliste · Branche · Geo · Dedup" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;fontStyle=1;" vertex="1" parent="uc1">
          <mxGeometry x="400" y="60" width="290" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc1-db" value="SQLite-Datenbestand
auftraege · positionen · module · kunden" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="uc1">
          <mxGeometry x="810" y="60" width="240" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc1-note" value="Batch, offline, wiederholbar" style="text;html=1;fontSize=11;fontStyle=2;align=center;fontColor=#666666;" vertex="1" parent="uc1">
          <mxGeometry x="400" y="135" width="290" height="20" as="geometry" />
        </mxCell>
        <mxCell id="uc1-e1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;" edge="1" parent="uc1" source="uc1-src" target="uc1-llm">
          <mxGeometry relative="1" as="geometry" />
        </mxCell>
        <mxCell id="uc1-e2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;" edge="1" parent="uc1" source="uc1-llm" target="uc1-db">
          <mxGeometry relative="1" as="geometry" />
        </mxCell>

        <!-- ================= Use Case 2 ================= -->
        <mxCell id="uc2" value="2 · KI zur Analyse — Fragen-Tab (ersetzt starres Reporting)" style="swimlane;horizontal=1;startSize=30;rounded=1;fillColor=#fafafa;strokeColor=#666666;fontSize=14;fontStyle=1;swimlaneFillColor=#ffffff;" vertex="1" parent="1">
          <mxGeometry x="40" y="290" width="1090" height="190" as="geometry" />
        </mxCell>
        <mxCell id="uc2-q" value="Frage in
natürlicher Sprache" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="uc2">
          <mxGeometry x="40" y="60" width="200" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc2-llm" value="LLM
Text → SQL" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;fontStyle=1;" vertex="1" parent="uc2">
          <mxGeometry x="310" y="60" width="200" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc2-db" value="SQLite
(Datenbestand aus UC 1)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="uc2">
          <mxGeometry x="580" y="60" width="200" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc2-viz" value="Auto-Visualisierung
Chart / Tabelle / Antwort" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=12;" vertex="1" parent="uc2">
          <mxGeometry x="850" y="60" width="200" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc2-note" value="Ad-hoc statt vordefinierter Reports" style="text;html=1;fontSize=11;fontStyle=2;align=center;fontColor=#666666;" vertex="1" parent="uc2">
          <mxGeometry x="310" y="135" width="470" height="20" as="geometry" />
        </mxCell>
        <mxCell id="uc2-e1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;" edge="1" parent="uc2" source="uc2-q" target="uc2-llm">
          <mxGeometry relative="1" as="geometry" />
        </mxCell>
        <mxCell id="uc2-e2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;" edge="1" parent="uc2" source="uc2-llm" target="uc2-db">
          <mxGeometry relative="1" as="geometry" />
        </mxCell>
        <mxCell id="uc2-e3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;" edge="1" parent="uc2" source="uc2-db" target="uc2-viz">
          <mxGeometry relative="1" as="geometry" />
        </mxCell>

        <!-- ================= Use Case 3 ================= -->
        <mxCell id="uc3" value="3 · KI mit eigenem LLM — Vorschlagsengine" style="swimlane;horizontal=1;startSize=30;rounded=1;fillColor=#fafafa;strokeColor=#666666;fontSize=14;fontStyle=1;swimlaneFillColor=#ffffff;" vertex="1" parent="1">
          <mxGeometry x="40" y="510" width="1090" height="190" as="geometry" />
        </mxCell>
        <mxCell id="uc3-src" value="Auftrags- & Kundenhistorie
(veredelter Datenbestand)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="uc3">
          <mxGeometry x="40" y="60" width="240" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc3-llm" value="Eigenes LLM
auf Bestandsdaten" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=12;fontStyle=1;" vertex="1" parent="uc3">
          <mxGeometry x="400" y="60" width="290" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc3-out" value="Vorschläge
Cross-Sell · Konfiguration" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=12;" vertex="1" parent="uc3">
          <mxGeometry x="810" y="60" width="240" height="70" as="geometry" />
        </mxCell>
        <mxCell id="uc3-e1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;" edge="1" parent="uc3" source="uc3-src" target="uc3-llm">
          <mxGeometry relative="1" as="geometry" />
        </mxCell>
        <mxCell id="uc3-e2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;" edge="1" parent="uc3" source="uc3-llm" target="uc3-out">
          <mxGeometry relative="1" as="geometry" />
        </mxCell>

      </root>
    </mxGraphModel>
  </diagram>
</mxfile>


ok -- kannst due eine DrawIo bauen, die die 3 UseCses für KI darstellt? -> KI zur Analyse und Aufbau des Datenbestandes (Nutzung als Tool) -> KI zur Analyse -> der FragenTab (ersetzt aufwändiges starr…


Erstellt: `/home/marcuh/projects/08-07/ki-usecases.drawio`

Aufbau — drei Lanes untereinander, je mit Fluss von links nach rechts:

1. **KI als Tool — Analyse & Aufbau des Datenbestandes**: .plax-Rohdaten → LLM-Extraktion (Fahrzeugtyp, Stückliste, Branche, Geo, Dedup) → SQLite-Datenbestand; Vermerk „Batch, offline, wiederholbar".
2. **KI zur Analyse — Fragen-Tab**: Frage in natürlicher Sprache → LLM Text→SQL → SQLite → Auto-Visualisierung; Vermerk „Ad-hoc statt vordefinierter Reports".
3. **KI mit eigenem LLM — Vorschlagsengine**: Auftrags-/Kundenhistorie → eigenes LLM → Vorschläge (Cross-Sell, Konfiguration).

LLM-Boxen farblich hervorgehoben (blau bzw. violett für das eigene LLM), Datenbestand grün, Ergebnisse orange.


Wir können in der app schon die Stückliste rendern?


Laut Memory ja: Die Stückliste (Bauteil-Klartext + Artikelnummern aus `module.klartext`) wird als Popup je Auftrag angezeigt. Den aktuellen Code-Stand habe ich nicht geprüft.


gut
Wir brauchen eine Oberfl√§che, in der ich ein Profil w√§hlen kann und


gut
Wir brauchen eine Oberfl√§che, in der ich ein Profil w√§hlen kann (branche / land / größe)--> müssen wir schauen
Dann noch eine Auto --> Vorschlagskonfuguration


Plan (kurz, dann warte ich auf Go):

**Neuer Tab „Vorschlag"** in der Vue-Oberfläche:

1. **Profilauswahl**: drei Dropdowns — Branche, Land, Größe. Werte kommen per SQL aus `kunden` (Branche/Geo sind durch die Veredelung vorhanden; ob „Größe" als Feld existiert, muss ich beim Umsetzen im Schema prüfen — sonst bleibt das Dropdown zunächst leer/deaktiviert).
2. **Fahrzeugauswahl**: Dropdown aus den vorhandenen `auftrag_fzg`-Werten.
3. **Button „Vorschlagskonfiguration"**: Rust-Command holt per SQL die Aufträge ähnlicher Profile (gleiche Branche/Land/Fahrzeugtyp) samt Modulen, gibt sie mit dem gewählten Profil an das LLM (gleicher Weg wie im Fragen-Tab) und bekommt eine vorgeschlagene Modulliste zurück — gerendert wie das Stücklisten-Popup.

Umfang: eine neue Vue-Komponente, ein neuer Rust-Command, keine Schema-Änderung.


bau das mal


Bash

find /home/marcuh/projects/08-07 -type f \( -name "*.rs" -o -name "*.vue" -o -name "*.ts" -o -name "*.js" -o -name "*.sql" -o -name "*.json" \) -not -path "*/node_modules/*" -not -path "*/target/*" -not -path "*/dist/*" | head -60

List source files of the 08-07 project


/home/marcuh/projects/08-07/tsconfig.node.json
/home/marcuh/projects/08-07/package-lock.json
/home/marcuh/projects/08-07/package.json
/home/marcuh/projects/08-07/tsconfig.json
/home/marcuh/projects/08-07/vite.config.ts
/home/marcuh/projects/08-07/pipeline/fx_cache.json
/home/marcuh/projects/08-07/pipeline/schema.sql
/home/marcuh/projects/08-07/pipeline/stueckliste.json
/home/marcuh/projects/08-07/pipeline/fzg_texts.json
/home/marcuh/projects/08-07/src-tauri/tauri.conf.json
/home/marcuh/projects/08-07/src-tauri/build.rs
/home/marcuh/projects/08-07/.vscode/extensions.json
/home/marcuh/projects/08-07/data/extracted_xml_data.json
/home/marcuh/projects/08-07/src/App.vue
/home/marcuh/projects/08-07/src/main.ts
/home/marcuh/projects/08-07/src/vite-env.d.ts
/home/marcuh/projects/08-07/src/catalog.ts
/home/marcuh/projects/08-07/pipeline/fzg_enrichment/result_03.json
/home/marcuh/projects/08-07/pipeline/fzg_enrichment/result_02.json
/home/marcuh/projects/08-07/pipeline/fzg_enrichment/result_05.json
/home/marcuh/projects/08-07/pipeline/fzg_enrichment/result_04.json
/home/marcuh/projects/08-07/pipeline/fzg_enrichment/result_07.json
/home/marcuh/projects/08-07/pipeline/fzg_enrichment/result_06.json
/home/marcuh/projects/08-07/pipeline/fzg_enrichment/result_01.json
/home/marcuh/projects/08-07/pipeline/fzg_enrichment/result_00.json
/home/marcuh/projects/08-07/pipeline/enrichment/result_03.json
/home/marcuh/projects/08-07/pipeline/enrichment/result_02.json
/home/marcuh/projects/08-07/pipeline/enrichment/result_09.json
/home/marcuh/projects/08-07/pipeline/enrichment/result_05.json
/home/marcuh/projects/08-07/pipeline/enrichment/result_04.json
/home/marcuh/projects/08-07/pipeline/enrichment/result_08.json
/home/marcuh/projects/08-07/pipeline/enrichment/result_07.json
/home/marcuh/projects/08-07/pipeline/enrichment/result_06.json
/home/marcuh/projects/08-07/pipeline/enrichment/result_01.json
/home/marcuh/projects/08-07/pipeline/enrichment/result_00.json
/home/marcuh/projects/08-07/src-tauri/capabilities/default.json
/home/marcuh/projects/08-07/src-tauri/src/lib.rs
/home/marcuh/projects/08-07/src-tauri/src/main.rs
/home/marcuh/projects/08-07/src-tauri/gen/schemas/capabilities.json
/home/marcuh/projects/08-07/src-tauri/gen/schemas/acl-manifests.json
/home/marcuh/projects/08-07/src-tauri/gen/schemas/macOS-schema.json
/home/marcuh/projects/08-07/src-tauri/gen/schemas/desktop-schema.json


1	-- Würth-KI-Demo — Datenmodell
2	-- Schicht 2 (Arbeit): aus extracted_xml_data.json normalisiert.
3	-- Schicht 3 (Veredelung): angereicherte Felder mit Quelle + Konfidenz, getrennt von den Rohwerten.
4	-- Roh-JSON bleibt Single Source of Truth.
5
6	PRAGMA foreign_keys = ON;
7
8	DROP TABLE IF EXISTS positionen;
9	DROP TABLE IF EXISTS auftraege;
10	DROP TABLE IF EXISTS kunde_firmendaten;
11	DROP TABLE IF EXISTS web_cache;
12	DROP TABLE IF EXISTS module;
13	DROP TABLE IF EXISTS kunden;
14
15	-- ---------------------------------------------------------------------------
16	-- Arbeitsschicht
17	-- ---------------------------------------------------------------------------
18
19	CREATE TABLE kunden (
20	  kunde_id     INTEGER PRIMARY KEY,
21	  name_roh     TEXT NOT NULL,   -- Original kde_name (entity-decoded)
22	  name_norm    TEXT NOT NULL,   -- Dedup-Schlüssel: lower, Whitespace kollabiert
23	  name_1       TEXT,
24	  name_2       TEXT,
25	  strasse      TEXT,
26	  plz          TEXT,
27	  landeskenner TEXT,            -- roh (NO / DE / Norway / Deutschland / leer)
28	  land         TEXT,            -- normiert (ISO-2)
29	  UNIQUE (name_norm, plz)
30	);
31
32	CREATE TABLE auftraege (
33	  auftrag_id                TEXT PRIMARY KEY,   -- export-Key
34	  kunde_id                  INTEGER NOT NULL REFERENCES kunden(kunde_id),
35	  bearbeiter                TEXT,               -- Kürzel aus dem Key
36	  datum                     TEXT,               -- ISO-Datum aus dem Key
37	  quotation_state           TEXT,               -- order | offer
38	  montage_art               TEXT,
39	  currency                  TEXT,               -- roh aus JSON (unzuverlässig, stets EUR)
40	  waehrung                  TEXT,               -- echte Landeswährung aus .plax (EUR / NOK / ...)
41	  wechselkurs               REAL,               -- Einheiten je EUR am kurs_datum (EUR = 1)
42	  kurs_datum                TEXT,               -- Handelstag des Kurses (ECB)
43	  wuerth_innen_landeskenner TEXT,
44	  -- Kopf / Preise
45	  summe_einkaufspreis       REAL,
46	  summe_positionen_liste    REAL,
47	  summe_positionen          REAL,
48	  montage_zeit              REAL,
49	  montage_wert              REAL,
50	  fracht                    REAL,
51	  rabatt_wert               REAL,
52	  rabatt_prozent            REAL,
53	  aufschlag_wert            REAL,
54	  aufschlag_prozent         REAL,
55	  kundenpreis_netto         REAL,
56	  aw_wert                   REAL,
57	  re                        REAL,
58	  ka                        REAL,
59	  -- Fahrzeug (roh, durchgängig leer — Kandidat fürs Nachfüllen in der Veredelung)
60	  fzg_hersteller_id         TEXT,
61	  fzg_typ                   TEXT,
62	  fzg_baujahr               TEXT,
63	  fzg_dach                  TEXT,
64	  fzg_antrieb               TEXT,
65	  fzg_leasing               INTEGER,
66	  fzg_rad_stand             TEXT,
67	  fzg_seitentuer            TEXT,
68	  fzg_wand                  TEXT
69	);
70
71	CREATE TABLE module (
72	  modul_id      TEXT PRIMARY KEY,
73	  klartext      TEXT,    -- Veredelung: lesbarer Name (aus PDF-Stückliste)
74	  artikelnummer TEXT,    -- Würth-Artikelnummer (aus PDF-Stückliste)
75	  kategorie     TEXT     -- Veredelung: Boden / Regal / Befestigung / Montageset / ...
76	);
77
78	CREATE TABLE positionen (
79	  position_id     INTEGER PRIMARY KEY,
80	  auftrag_id      TEXT NOT NULL REFERENCES auftraege(auftrag_id),
81	  modul_id        TEXT NOT NULL REFERENCES module(modul_id),
82	  menge           INTEGER,        -- Summe der COMP-Mengen aus struktur_data_json
83	  einkaufspreis   REAL,
84	  preis_liste     REAL,
85	  preis           REAL,
86	  rabatt_wert     REAL,
87	  rabatt_prozent  REAL,           -- abgeleitet: rabatt_wert / preis_liste
88	  aufschlag_wert  REAL,
89	  aw_wert         REAL,
90	  typ             TEXT,
91	  UNIQUE (auftrag_id, modul_id)
92	);
93
94	-- ---------------------------------------------------------------------------
95	-- Veredelungsschicht (Firmendaten aus dem Internet)
96	-- ---------------------------------------------------------------------------
97
98	-- Roh-Cache je Web-Abruf: Anreicherung läuft einmal offline, Demo bleibt
99	-- deterministisch und ohne Netz; erneut ausführbar ohne neue Web-Calls.
100	CREATE TABLE web_cache (
101	  cache_id     INTEGER PRIMARY KEY,
102	  query_name   TEXT,        -- Firmenname wie abgefragt
103	  query_plz    TEXT,
104	  quelle       TEXT,        -- handelsregister | northdata | google_places | website | llm_web
105	  abgerufen_am TEXT,
106	  http_status  INTEGER,
107	  raw_json     TEXT,        -- unveränderte Antwort
108	  UNIQUE (query_name, query_plz, quelle)
109	);
110
111	-- Konsolidierte, veredelte Firma je Kunde (1:1 nach Merge der Quellen).
112	CREATE TABLE kunde_firmendaten (
113	  kunde_id        INTEGER PRIMARY KEY REFERENCES kunden(kunde_id),
114	  branche_nace    TEXT,     -- normierter Code (NACE / WZ)
115	  branche_text    TEXT,     -- Klartext
116	  gewerk          TEXT,     -- Keymap (src/catalog.ts): Kfz / SHK/HLK / Bau-Dach / Elektro / Holz-Zimmerei / Fördertechnik / Industrie / Handel / öffentlich / Sonstige
117	  segment         TEXT,     -- Handwerk | Industrie | Flotte | öffentlich
118	  mitarbeiter_kl  TEXT,     -- Größenklasse statt exakter Zahl
119	  umsatz_kl       TEXT,
120	  gruendungsjahr  INTEGER,
121	  rechtsform      TEXT,
122	  website         TEXT,
123	  geo_lat         REAL,
124	  geo_lon         REAL,
125	  ort             TEXT,
126	  bundesland      TEXT,
127	  hr_nummer       TEXT,     -- Handelsregister-Beleg für den Match
128	  konfidenz       REAL,     -- 0..1
129	  bestaetigt      INTEGER,  -- 1 = über Sitz/HR bestätigt, 0 = unbestätigt
130	  quelle_primaer  TEXT,     -- welche Web-Quelle den Match getragen hat
131	  veredelt_am     TEXT
132	);
133
134	-- Fahrzeug je Auftrag, aus den .plax-Rohtexten per LLM extrahiert (Veredelung).
135	CREATE TABLE auftrag_fzg (
136	  auftrag_id   TEXT PRIMARY KEY REFERENCES auftraege(auftrag_id),
137	  fzg_klasse   TEXT,    -- van | pickup | pkw | lkw | unbekannt
138	  hersteller   TEXT,
139	  modell       TEXT,
140	  groesse      TEXT,    -- L1H1 / L3H2 / MH1 ...
141	  baujahr      TEXT,
142	  konfidenz    REAL,
143	  quelle       TEXT,    -- pdf | subject | interner_text | modulcode | keine
144	  veredelt_am  TEXT
145	);
146
147	CREATE INDEX idx_positionen_modul   ON positionen(modul_id);
148	CREATE INDEX idx_positionen_auftrag ON positionen(auftrag_id);
149	CREATE INDEX idx_auftraege_kunde    ON auftraege(kunde_id);
150
151	-- ---------------------------------------------------------------------------
152	-- Sicht: geliefert (links) vs. angereichert (rechts), eine Zeile je Kunde
153	-- ---------------------------------------------------------------------------
154	DROP VIEW IF EXISTS v_kunde_360;
155	CREATE VIEW v_kunde_360 AS
156	SELECT
157	  k.kunde_id,
158	  -- geliefert (Plato-Export)
159	  k.name_roh,
160	  k.name_1,
161	  k.strasse,
162	  k.plz,
163	  k.landeskenner,
164	  k.land,
165	  COUNT(DISTINCT a.auftrag_id)   AS n_auftraege,
166	  SUM(a.kundenpreis_netto / COALESCE(a.wechselkurs, 1)) AS umsatz_netto,  -- EUR-normiert
167
168	  -- angereichert (Web-Recherche)
169	  f.branche_text,
170	  f.branche_nace,
171	  f.gewerk,
172	  f.segment,
173	  f.mitarbeiter_kl,
174	  f.umsatz_kl,
175	  f.gruendungsjahr,
176	  f.rechtsform,
177	  f.website,
178	  f.ort,
179	  f.bundesland,
180	  f.hr_nummer,
181	  f.konfidenz,
182	  f.bestaetigt,
183	  f.quelle_primaer
184	FROM kunden k
185	LEFT JOIN auftraege a USING (kunde_id)
186	LEFT JOIN kunde_firmendaten f USING (kunde_id)
187	GROUP BY k.kunde_id;
188

-- Würth-KI-Demo — Datenmodell
-- Schicht 2 (Arbeit): aus extracted_xml_data.json normalisiert.
-- Schicht 3 (Veredelung): angereicherte Felder mit Quelle + Konfidenz, getrennt von den Rohwerten.
-- Roh-JSON bleibt Single Source of Truth.

PRAGMA foreign_keys = ON;

DROP TABLE IF EXISTS positionen;
DROP TABLE IF EXISTS auftraege;
DROP TABLE IF EXISTS kunde_firmendaten;
DROP TABLE IF EXISTS web_cache;
DROP TABLE IF EXISTS module;
DROP TABLE IF EXISTS kunden;

-- ---------------------------------------------------------------------------
-- Arbeitsschicht
-- ---------------------------------------------------------------------------

CREATE TABLE kunden (
  kunde_id     INTEGER PRIMARY KEY,
  name_roh     TEXT NOT NULL,   -- Original kde_name (entity-decoded)
  name_norm    TEXT NOT NULL,   -- Dedup-Schlüssel: lower, Whitespace kollabiert
  name_1       TEXT,
  name_2       TEXT,
  strasse      TEXT,
  plz          TEXT,
  landeskenner TEXT,            -- roh (NO / DE / Norway / Deutschland / leer)
  land         TEXT,            -- normiert (ISO-2)
  UNIQUE (name_norm, plz)
);

CREATE TABLE auftraege (
  auftrag_id                TEXT PRIMARY KEY,   -- export-Key
  kunde_id                  INTEGER NOT NULL REFERENCES kunden(kunde_id),
  bearbeiter                TEXT,               -- Kürzel aus dem Key
  datum                     TEXT,               -- ISO-Datum aus dem Key
  quotation_state           TEXT,               -- order | offer
  montage_art               TEXT,
  currency                  TEXT,               -- roh aus JSON (unzuverlässig, stets EUR)
  waehrung                  TEXT,               -- echte Landeswährung aus .plax (EUR / NOK / ...)
  wechselkurs               REAL,               -- Einheiten je EUR am kurs_datum (EUR = 1)
  kurs_datum                TEXT,               -- Handelstag des Kurses (ECB)
  wuerth_innen_landeskenner TEXT,
  -- Kopf / Preise
  summe_einkaufspreis       REAL,
  summe_positionen_liste    REAL,
  summe_positionen          REAL,
  montage_zeit              REAL,
  montage_wert              REAL,
  fracht                    REAL,
  rabatt_wert               REAL,
  rabatt_prozent            REAL,
  aufschlag_wert            REAL,
  aufschlag_prozent         REAL,
  kundenpreis_netto         REAL,
  aw_wert                   REAL,
  re                        REAL,
  ka                        REAL,
  -- Fahrzeug (roh, durchgängig leer — Kandidat fürs Nachfüllen in der Veredelung)
  fzg_hersteller_id         TEXT,
  fzg_typ                   TEXT,
  fzg_baujahr               TEXT,
  fzg_dach                  TEXT,
  fzg_antrieb               TEXT,
  fzg_leasing               INTEGER,
  fzg_rad_stand             TEXT,
  fzg_seitentuer            TEXT,
  fzg_wand                  TEXT
);

CREATE TABLE module (
  modul_id      TEXT PRIMARY KEY,
  klartext      TEXT,    -- Veredelung: lesbarer Name (aus PDF-Stückliste)
  artikelnummer TEXT,    -- Würth-Artikelnummer (aus PDF-Stückliste)
  kategorie     TEXT     -- Veredelung: Boden / Regal / Befestigung / Montageset / ...
);

CREATE TABLE positionen (
  position_id     INTEGER PRIMARY KEY,
  auftrag_id      TEXT NOT NULL REFERENCES auftraege(auftrag_id),
  modul_id        TEXT NOT NULL REFERENCES module(modul_id),
  menge           INTEGER,        -- Summe der COMP-Mengen aus struktur_data_json
  einkaufspreis   REAL,
  preis_liste     REAL,
  preis           REAL,
  rabatt_wert     REAL,
  rabatt_prozent  REAL,           -- abgeleitet: rabatt_wert / preis_liste
  aufschlag_wert  REAL,
  aw_wert         REAL,
  typ             TEXT,
  UNIQUE (auftrag_id, modul_id)
);

-- ---------------------------------------------------------------------------
-- Veredelungsschicht (Firmendaten aus dem Internet)
-- ---------------------------------------------------------------------------

-- Roh-Cache je Web-Abruf: Anreicherung läuft einmal offline, Demo bleibt
-- deterministisch und ohne Netz; erneut ausführbar ohne neue Web-Calls.
CREATE TABLE web_cache (
  cache_id     INTEGER PRIMARY KEY,
  query_name   TEXT,        -- Firmenname wie abgefragt
  query_plz    TEXT,
  quelle       TEXT,        -- handelsregister | northdata | google_places | website | llm_web
  abgerufen_am TEXT,
  http_status  INTEGER,
  raw_json     TEXT,        -- unveränderte Antwort
  UNIQUE (query_name, query_plz, quelle)
);

-- Konsolidierte, veredelte Firma je Kunde (1:1 nach Merge der Quellen).
CREATE TABLE kunde_firmendaten (
  kunde_id        INTEGER PRIMARY KEY REFERENCES kunden(kunde_id),
  branche_nace    TEXT,     -- normierter Code (NACE / WZ)
  branche_text    TEXT,     -- Klartext
  gewerk          TEXT,     -- Keymap (src/catalog.ts): Kfz / SHK/HLK / Bau-Dach / Elektro / Holz-Zimmerei / Fördertechnik / Industrie / Handel / öffentlich / Sonstige
  segment         TEXT,     -- Handwerk | Industrie | Flotte | öffentlich
  mitarbeiter_kl  TEXT,     -- Größenklasse statt exakter Zahl
  umsatz_kl       TEXT,
  gruendungsjahr  INTEGER,
  rechtsform      TEXT,
  website         TEXT,
  geo_lat         REAL,
  geo_lon         REAL,
  ort             TEXT,
  bundesland      TEXT,
  hr_nummer       TEXT,     -- Handelsregister-Beleg für den Match
  konfidenz       REAL,     -- 0..1
  bestaetigt      INTEGER,  -- 1 = über Sitz/HR bestätigt, 0 = unbestätigt
  quelle_primaer  TEXT,     -- welche Web-Quelle den Match getragen hat
  veredelt_am     TEXT
);

-- Fahrzeug je Auftrag, aus den .plax-Rohtexten per LLM extrahiert (Veredelung).
CREATE TABLE auftrag_fzg (
  auftrag_id   TEXT PRIMARY KEY REFERENCES auftraege(auftrag_id),
  fzg_klasse   TEXT,    -- van | pickup | pkw | lkw | unbekannt
  hersteller   TEXT,
  modell       TEXT,
  groesse      TEXT,    -- L1H1 / L3H2 / MH1 ...
  baujahr      TEXT,
  konfidenz    REAL,
  quelle       TEXT,    -- pdf | subject | interner_text | modulcode | keine
  veredelt_am  TEXT
);

CREATE INDEX idx_positionen_modul   ON positionen(modul_id);
CREATE INDEX idx_positionen_auftrag ON positionen(auftrag_id);
CREATE INDEX idx_auftraege_kunde    ON auftraege(kunde_id);

-- ---------------------------------------------------------------------------
-- Sicht: geliefert (links) vs. angereichert (rechts), eine Zeile je Kunde
-- ---------------------------------------------------------------------------
DROP VIEW IF EXISTS v_kunde_360;
CREATE VIEW v_kunde_360 AS
SELECT
  k.kunde_id,
  -- geliefert (Plato-Export)
  k.name_roh,
  k.name_1,
  k.strasse,
  k.plz,
  k.landeskenner,
  k.land,
  COUNT(DISTINCT a.auftrag_id)   AS n_auftraege,
  SUM(a.kundenpreis_netto / COALESCE(a.wechselkurs, 1)) AS umsatz_netto,  -- EUR-normiert

  -- angereichert (Web-Recherche)
  f.branche_text,
  f.branche_nace,
  f.gewerk,
  f.segment,
  f.mitarbeiter_kl,
  f.umsatz_kl,
  f.gruendungsjahr,
  f.rechtsform,
  f.website,
  f.ort,
  f.bundesland,
  f.hr_nummer,
  f.konfidenz,
  f.bestaetigt,
  f.quelle_primaer
FROM kunden k
LEFT JOIN auftraege a USING (kunde_id)
LEFT JOIN kunde_firmendaten f USING (kunde_id)
GROUP BY k.kunde_id;


1	use rusqlite::Connection;
2	use serde::Serialize;
3
4	const DB_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../data/wuerth.db");
5
6	#[derive(Serialize)]
7	struct Kunde360 {
8	    kunde_id: i64,
9	    // geliefert
10	    name_roh: String,
11	    name_1: Option<String>,
12	    strasse: Option<String>,
13	    plz: Option<String>,
14	    land: Option<String>,
15	    n_auftraege: i64,
16	    umsatz_netto: f64,
17	    // angereichert
18	    branche_text: Option<String>,
19	    gewerk: Option<String>,
20	    segment: Option<String>,
21	    mitarbeiter_kl: Option<String>,
22	    gruendungsjahr: Option<i64>,
23	    rechtsform: Option<String>,
24	    website: Option<String>,
25	    ort: Option<String>,
26	    konfidenz: Option<f64>,
27	    bestaetigt: Option<i64>,
28	    quelle_primaer: Option<String>,
29	}
30
31	#[tauri::command]
32	fn list_kunden_360() -> Result<Vec<Kunde360>, String> {
33	    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
34	    let mut stmt = con
35	        .prepare(
36	            "SELECT kunde_id, name_roh, name_1, strasse, plz, land, n_auftraege, \
37	             umsatz_netto, branche_text, gewerk, segment, mitarbeiter_kl, gruendungsjahr, \
38	             rechtsform, website, ort, konfidenz, bestaetigt, quelle_primaer \
39	             FROM v_kunde_360 ORDER BY name_roh",
40	        )
41	        .map_err(|e| e.to_string())?;
42	    let rows = stmt
43	        .query_map([], |r| {
44	            Ok(Kunde360 {
45	                kunde_id: r.get(0)?,
46	                name_roh: r.get(1)?,
47	                name_1: r.get(2)?,
48	                strasse: r.get(3)?,
49	                plz: r.get(4)?,
50	                land: r.get(5)?,
51	                n_auftraege: r.get(6)?,
52	                umsatz_netto: r.get(7)?,
53	                branche_text: r.get(8)?,
54	                gewerk: r.get(9)?,
55	                segment: r.get(10)?,
56	                mitarbeiter_kl: r.get(11)?,
57	                gruendungsjahr: r.get(12)?,
58	                rechtsform: r.get(13)?,
59	                website: r.get(14)?,
60	                ort: r.get(15)?,
61	                konfidenz: r.get(16)?,
62	                bestaetigt: r.get(17)?,
63	                quelle_primaer: r.get(18)?,
64	            })
65	        })
66	        .map_err(|e| e.to_string())?;
67	    let mut out = Vec::new();
68	    for row in rows {
69	        out.push(row.map_err(|e| e.to_string())?);
70	    }
71	    Ok(out)
72	}
73
74	#[derive(Serialize)]
75	struct Auftrag {
76	    kunde_id: i64,
77	    auftrag_id: String,
78	    datum: Option<String>,
79	    fzg_klasse: Option<String>,
80	    fzg_hersteller: Option<String>,
81	    fzg_modell: Option<String>,
82	    fzg_groesse: Option<String>,
83	    umsatz_netto: f64, // Landeswährung
84	    umsatz_eur: f64,   // umgerechnet
85	    waehrung: Option<String>,
86	    wechselkurs: Option<f64>,
87	    kurs_datum: Option<String>,
88	    quotation_state: Option<String>,
89	}
90
91	#[tauri::command]
92	fn list_auftraege() -> Result<Vec<Auftrag>, String> {
93	    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
94	    let mut stmt = con
95	        .prepare(
96	            "SELECT a.kunde_id, a.auftrag_id, a.datum, f.fzg_klasse, f.hersteller, \
97	             f.modell, f.groesse, a.kundenpreis_netto, \
98	             a.kundenpreis_netto / COALESCE(a.wechselkurs, 1) AS umsatz_eur, \
99	             a.waehrung, a.wechselkurs, a.kurs_datum, a.quotation_state \
100	             FROM auftraege a LEFT JOIN auftrag_fzg f USING (auftrag_id) \
101	             ORDER BY a.datum DESC",
102	        )
103	        .map_err(|e| e.to_string())?;
104	    let rows = stmt
105	        .query_map([], |r| {
106	            Ok(Auftrag {
107	                kunde_id: r.get(0)?,
108	                auftrag_id: r.get(1)?,
109	                datum: r.get(2)?,
110	                fzg_klasse: r.get(3)?,
111	                fzg_hersteller: r.get(4)?,
112	                fzg_modell: r.get(5)?,
113	                fzg_groesse: r.get(6)?,
114	                umsatz_netto: r.get(7)?,
115	                umsatz_eur: r.get(8)?,
116	                waehrung: r.get(9)?,
117	                wechselkurs: r.get(10)?,
118	                kurs_datum: r.get(11)?,
119	                quotation_state: r.get(12)?,
120	            })
121	        })
122	        .map_err(|e| e.to_string())?;
123	    let mut out = Vec::new();
124	    for row in rows {
125	        out.push(row.map_err(|e| e.to_string())?);
126	    }
127	    Ok(out)
128	}
129
130	#[derive(Serialize)]
131	struct Position {
132	    modul_id: String,
133	    artikelnummer: Option<String>,
134	    klartext: String,
135	    menge: i64,
136	    einzelpreis: f64,
137	    pos_wert: f64,
138	    eur_wert: f64,
139	}
140
141	#[tauri::command]
142	fn list_positionen(auftrag_id: String) -> Result<Vec<Position>, String> {
143	    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
144	    let mut stmt = con
145	        .prepare(
146	            "SELECT p.modul_id, m.artikelnummer, COALESCE(m.klartext, p.modul_id), \
147	             COALESCE(p.menge, 1), p.preis, p.preis * COALESCE(p.menge, 1) AS pos_wert, \
148	             p.preis * COALESCE(p.menge, 1) / COALESCE(a.wechselkurs, 1) AS eur_wert \
149	             FROM positionen p LEFT JOIN module m USING (modul_id) \
150	             JOIN auftraege a ON a.auftrag_id = p.auftrag_id \
151	             WHERE p.auftrag_id = ?1 ORDER BY pos_wert DESC",
152	        )
153	        .map_err(|e| e.to_string())?;
154	    let rows = stmt
155	        .query_map([auftrag_id], |r| {
156	            Ok(Position {
157	                modul_id: r.get(0)?,
158	                artikelnummer: r.get(1)?,
159	                klartext: r.get(2)?,
160	                menge: r.get(3)?,
161	                einzelpreis: r.get(4)?,
162	                pos_wert: r.get(5)?,
163	                eur_wert: r.get(6)?,
164	            })
165	        })
166	        .map_err(|e| e.to_string())?;
167	    let mut out = Vec::new();
168	    for row in rows {
169	        out.push(row.map_err(|e| e.to_string())?);
170	    }
171	    Ok(out)
172	}
173
174	// ---------------------------------------------------------------------------
175	// Natursprache-Assistent: Frage -> Claude erzeugt read-only SQL + Chart-Spec
176	// -> Rust prüft (nur SELECT) und führt aus -> Frontend rendert.
177	// ---------------------------------------------------------------------------
178
179	#[derive(Serialize)]
180	struct AiAntwort {
181	    frage: String,
182	    sql: String,
183	    chart: String,             // kpi | bar | table
184	    label: Option<String>,     // Spaltenname für Kategorie
185	    value: Option<String>,     // Spaltenname für Wert
186	    titel: String,
187	    erklaerung: String,
188	    columns: Vec<String>,
189	    rows: Vec<Vec<serde_json::Value>>,
190	}
191
192	fn read_api_key() -> Result<String, String> {
193	    let home = std::env::var("HOME").map_err(|_| "HOME nicht gesetzt".to_string())?;
194	    let path = format!("{home}/.ssh/anthropic_api_key");
195	    let content =
196	        std::fs::read_to_string(&path).map_err(|e| format!("Key-Datei {path}: {e}"))?;
197	    content
198	        .lines()
199	        .map(|l| l.trim())
200	        .find(|l| l.starts_with("sk-ant-"))
201	        .map(|s| s.to_string())
202	        .ok_or_else(|| format!("kein sk-ant-Key in {path}"))
203	}
204
205	fn db_schema(con: &Connection) -> Result<String, String> {
206	    let mut stmt = con
207	        .prepare(
208	            "SELECT sql FROM sqlite_master \
209	             WHERE type IN ('table','view') AND sql IS NOT NULL \
210	             AND name NOT LIKE 'sqlite_%' ORDER BY type DESC, name",
211	        )
212	        .map_err(|e| e.to_string())?;
213	    let rows = stmt
214	        .query_map([], |r| r.get::<_, String>(0))
215	        .map_err(|e| e.to_string())?;
216	    let mut out = String::new();
217	    for r in rows {
218	        out.push_str(&r.map_err(|e| e.to_string())?);
219	        out.push_str(";\n");
220	    }
221	    Ok(out)
222	}
223
224	fn is_read_only(sql: &str) -> bool {
225	    let up = sql.trim().to_uppercase();
226	    if !(up.starts_with("SELECT") || up.starts_with("WITH")) {
227	        return false;
228	    }
229	    // kein Statement-Trenner (nur SELECT), keine schreibenden Schlüsselwörter
230	    if sql.trim_end().trim_end_matches(';').contains(';') {
231	        return false;
232	    }
233	    const VERBOTEN: [&str; 9] = [
234	        "INSERT ", "UPDATE ", "DELETE ", "DROP ", "ALTER ", "CREATE ", "ATTACH ",
235	        "PRAGMA ", "REPLACE ",
236	    ];
237	    !VERBOTEN.iter().any(|w| up.contains(w))
238	}
239
240	fn cell_to_json(v: rusqlite::types::ValueRef) -> serde_json::Value {
241	    use rusqlite::types::ValueRef;
242	    match v {
243	        ValueRef::Null => serde_json::Value::Null,
244	        ValueRef::Integer(n) => serde_json::Value::from(n),
245	        ValueRef::Real(f) => serde_json::Value::from(f),
246	        ValueRef::Text(t) => serde_json::Value::from(String::from_utf8_lossy(t).to_string()),
247	        ValueRef::Blob(_) => serde_json::Value::from("<blob>"),
248	    }
249	}
250
251	const SYSTEM_TMPL: &str = "Du übersetzt deutsche Fragen in **eine** SQLite-Abfrage über die \
252	folgende Datenbank und wählst die beste Visualisierung. Antworte AUSSCHLIESSLICH mit einem \
253	JSON-Objekt, keine Erklärung davor/danach, keine Markdown-Zäune.\n\n\
254	Schema:\n{SCHEMA}\n\
255	Hinweise:\n\
256	- Nur eine read-only SELECT-Abfrage (ggf. mit WITH). Kein Semikolon, kein INSERT/UPDATE/DELETE/DDL.\n\
257	- Beträge liegen in Landeswährung; EUR = Betrag / COALESCE(wechselkurs,1). auftraege.kundenpreis_netto ist der Auftragswert.\n\
258	- v_kunde_360.umsatz_netto ist bereits EUR-normiert. gewerk/land/segment kommen aus der Anreicherung.\n\
259	- Kunden-Anzeigename ist kunden.name_roh (bzw. v_kunde_360.name_roh), NICHT name_1.\n\
260	- Für Rankings/Vergleiche LIMIT setzen (z.B. 10). Spaltennamen sprechend aliasen.\n\n\
261	JSON-Format:\n\
262	{\"sql\": \"<SELECT ...>\", \"chart\": \"kpi|bar|table\", \"label\": \"<Spalte für Kategorie oder null>\", \
263	\"value\": \"<Spalte für Wert oder null>\", \"titel\": \"<kurzer Titel>\", \"erklaerung\": \"<1 Satz>\"}\n\
264	- chart=kpi: eine Kennzahl (value = die Wertspalte). chart=bar: label + value. chart=table: sonst.";
265
266	fn frage_ai_inner(frage: String, modell: String, thinking: bool) -> Result<AiAntwort, String> {
267	    let key = read_api_key()?;
268	    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
269	    let schema = db_schema(&con)?;
270	    let system = SYSTEM_TMPL.replace("{SCHEMA}", &schema);
271
272	    // Modell auf erlaubte Werte begrenzen
273	    let modell = match modell.as_str() {
274	        "claude-opus-4-8" | "claude-sonnet-5" | "claude-haiku-4-5" => modell,
275	        _ => "claude-sonnet-5".to_string(),
276	    };
277	    // Thinking-Konfiguration je Modell; bei Thinking mehr Output-Budget
278	    let (thinking_cfg, max_tokens) = if !thinking {
279	        (serde_json::json!({"type": "disabled"}), 1024)
280	    } else if modell.starts_with("claude-haiku") {
281	        (serde_json::json!({"type": "enabled", "budget_tokens": 2000}), 4096)
282	    } else {
283	        (serde_json::json!({"type": "adaptive"}), 4096)
284	    };
285
286	    let body = serde_json::json!({
287	        "model": modell,
288	        "max_tokens": max_tokens,
289	        "thinking": thinking_cfg,
290	        "system": system,
291	        "messages": [{"role": "user", "content": frage}]
292	    });
293
294	    let client = reqwest::blocking::Client::builder()
295	        .timeout(std::time::Duration::from_secs(60))
296	        .build()
297	        .map_err(|e| e.to_string())?;
298	    let resp = client
299	        .post("https://api.anthropic.com/v1/messages")
300	        .header("x-api-key", key)
301	        .header("anthropic-version", "2023-06-01")
302	        .header("content-type", "application/json")
303	        .json(&body)
304	        .send()
305	        .map_err(|e| format!("API-Aufruf fehlgeschlagen: {e}"))?;
306
307	    let status = resp.status();
308	    let json: serde_json::Value = resp.json().map_err(|e| e.to_string())?;
309	    if !status.is_success() {
310	        return Err(format!("API {status}: {}", json));
311	    }
312	    // Im Thinking-Modus steht ein thinking-Block vor dem text-Block -> gezielt den text-Block nehmen
313	    let text = json["content"]
314	        .as_array()
315	        .and_then(|blocks| {
316	            blocks
317	                .iter()
318	                .find(|b| b["type"] == "text")
319	                .and_then(|b| b["text"].as_str())
320	        })
321	        .ok_or_else(|| format!("kein Text in API-Antwort: {json}"))?;
322
323	    // JSON-Objekt aus dem Text herausschneiden (erstes { bis letztes })
324	    let start = text.find('{').ok_or("kein JSON in der Antwort")?;
325	    let end = text.rfind('}').ok_or("kein JSON in der Antwort")?;
326	    let spec: serde_json::Value =
327	        serde_json::from_str(&text[start..=end]).map_err(|e| format!("JSON-Parse: {e}"))?;
328
329	    let sql = spec["sql"].as_str().unwrap_or("").trim().to_string();
330	    if !is_read_only(&sql) {
331	        return Err(format!("Abfrage abgelehnt (nicht read-only): {sql}"));
332	    }
333	    let chart = spec["chart"].as_str().unwrap_or("table").to_string();
334	    let label = spec["label"].as_str().map(|s| s.to_string());
335	    let value = spec["value"].as_str().map(|s| s.to_string());
336	    let titel = spec["titel"].as_str().unwrap_or("").to_string();
337	    let erklaerung = spec["erklaerung"].as_str().unwrap_or("").to_string();
338
339	    let mut stmt = con.prepare(&sql).map_err(|e| format!("SQL-Fehler: {e}"))?;
340	    let columns: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
341	    let ncol = columns.len();
342	    let rows_iter = stmt
343	        .query_map([], |r| {
344	            let mut row = Vec::with_capacity(ncol);
345	            for i in 0..ncol {
346	                row.push(cell_to_json(r.get_ref(i)?));
347	            }
348	            Ok(row)
349	        })
350	        .map_err(|e| e.to_string())?;
351	    let mut rows = Vec::new();
352	    for r in rows_iter {
353	        rows.push(r.map_err(|e| e.to_string())?);
354	    }
355
356	    Ok(AiAntwort {
357	        frage,
358	        sql,
359	        chart,
360	        label,
361	        value,
362	        titel,
363	        erklaerung,
364	        columns,
365	        rows,
366	    })
367	}
368
369	#[tauri::command]
370	async fn frage_ai(frage: String, modell: String, thinking: bool) -> Result<AiAntwort, String> {
371	    tauri::async_runtime::spawn_blocking(move || frage_ai_inner(frage, modell, thinking))
372	        .await
373	        .map_err(|e| e.to_string())?
374	}
375
376	#[cfg_attr(mobile, tauri::mobile_entry_point)]
377	pub fn run() {
378	    tauri::Builder::default()
379	        .plugin(tauri_plugin_opener::init())
380	        .invoke_handler(tauri::generate_handler![
381	            list_kunden_360,
382	            list_auftraege,
383	            list_positionen,
384	            frage_ai
385	        ])
386	        .run(tauri::generate_context!())
387	        .expect("error while running tauri application");
388	}
389

use rusqlite::Connection;
use serde::Serialize;

const DB_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../data/wuerth.db");

#[derive(Serialize)]
struct Kunde360 {
    kunde_id: i64,
    // geliefert
    name_roh: String,
    name_1: Option<String>,
    strasse: Option<String>,
    plz: Option<String>,
    land: Option<String>,
    n_auftraege: i64,
    umsatz_netto: f64,
    // angereichert
    branche_text: Option<String>,
    gewerk: Option<String>,
    segment: Option<String>,
    mitarbeiter_kl: Option<String>,
    gruendungsjahr: Option<i64>,
    rechtsform: Option<String>,
    website: Option<String>,
    ort: Option<String>,
    konfidenz: Option<f64>,
    bestaetigt: Option<i64>,
    quelle_primaer: Option<String>,
}

#[tauri::command]
fn list_kunden_360() -> Result<Vec<Kunde360>, String> {
    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
    let mut stmt = con
        .prepare(
            "SELECT kunde_id, name_roh, name_1, strasse, plz, land, n_auftraege, \
             umsatz_netto, branche_text, gewerk, segment, mitarbeiter_kl, gruendungsjahr, \
             rechtsform, website, ort, konfidenz, bestaetigt, quelle_primaer \
             FROM v_kunde_360 ORDER BY name_roh",
        )
        .map_err(|e| e.to_string())?;
    let rows = stmt
        .query_map([], |r| {
            Ok(Kunde360 {
                kunde_id: r.get(0)?,
                name_roh: r.get(1)?,
                name_1: r.get(2)?,
                strasse: r.get(3)?,
                plz: r.get(4)?,
                land: r.get(5)?,
                n_auftraege: r.get(6)?,
                umsatz_netto: r.get(7)?,
                branche_text: r.get(8)?,
                gewerk: r.get(9)?,
                segment: r.get(10)?,
                mitarbeiter_kl: r.get(11)?,
                gruendungsjahr: r.get(12)?,
                rechtsform: r.get(13)?,
                website: r.get(14)?,
                ort: r.get(15)?,
                konfidenz: r.get(16)?,
                bestaetigt: r.get(17)?,
                quelle_primaer: r.get(18)?,
            })
        })
        .map_err(|e| e.to_string())?;
    let mut out = Vec::new();
    for row in rows {
        out.push(row.map_err(|e| e.to_string())?);
    }
    Ok(out)
}

#[derive(Serialize)]
struct Auftrag {
    kunde_id: i64,
    auftrag_id: String,
    datum: Option<String>,
    fzg_klasse: Option<String>,
    fzg_hersteller: Option<String>,
    fzg_modell: Option<String>,
    fzg_groesse: Option<String>,
    umsatz_netto: f64, // Landeswährung
    umsatz_eur: f64,   // umgerechnet
    waehrung: Option<String>,
    wechselkurs: Option<f64>,
    kurs_datum: Option<String>,
    quotation_state: Option<String>,
}

#[tauri::command]
fn list_auftraege() -> Result<Vec<Auftrag>, String> {
    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
    let mut stmt = con
        .prepare(
            "SELECT a.kunde_id, a.auftrag_id, a.datum, f.fzg_klasse, f.hersteller, \
             f.modell, f.groesse, a.kundenpreis_netto, \
             a.kundenpreis_netto / COALESCE(a.wechselkurs, 1) AS umsatz_eur, \
             a.waehrung, a.wechselkurs, a.kurs_datum, a.quotation_state \
             FROM auftraege a LEFT JOIN auftrag_fzg f USING (auftrag_id) \
             ORDER BY a.datum DESC",
        )
        .map_err(|e| e.to_string())?;
    let rows = stmt
        .query_map([], |r| {
            Ok(Auftrag {
                kunde_id: r.get(0)?,
                auftrag_id: r.get(1)?,
                datum: r.get(2)?,
                fzg_klasse: r.get(3)?,
                fzg_hersteller: r.get(4)?,
                fzg_modell: r.get(5)?,
                fzg_groesse: r.get(6)?,
                umsatz_netto: r.get(7)?,
                umsatz_eur: r.get(8)?,
                waehrung: r.get(9)?,
                wechselkurs: r.get(10)?,
                kurs_datum: r.get(11)?,
                quotation_state: r.get(12)?,
            })
        })
        .map_err(|e| e.to_string())?;
    let mut out = Vec::new();
    for row in rows {
        out.push(row.map_err(|e| e.to_string())?);
    }
    Ok(out)
}

#[derive(Serialize)]
struct Position {
    modul_id: String,
    artikelnummer: Option<String>,
    klartext: String,
    menge: i64,
    einzelpreis: f64,
    pos_wert: f64,
    eur_wert: f64,
}

#[tauri::command]
fn list_positionen(auftrag_id: String) -> Result<Vec<Position>, String> {
    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
    let mut stmt = con
        .prepare(
            "SELECT p.modul_id, m.artikelnummer, COALESCE(m.klartext, p.modul_id), \
             COALESCE(p.menge, 1), p.preis, p.preis * COALESCE(p.menge, 1) AS pos_wert, \
             p.preis * COALESCE(p.menge, 1) / COALESCE(a.wechselkurs, 1) AS eur_wert \
             FROM positionen p LEFT JOIN module m USING (modul_id) \
             JOIN auftraege a ON a.auftrag_id = p.auftrag_id \
             WHERE p.auftrag_id = ?1 ORDER BY pos_wert DESC",
        )
        .map_err(|e| e.to_string())?;
    let rows = stmt
        .query_map([auftrag_id], |r| {
            Ok(Position {
                modul_id: r.get(0)?,
                artikelnummer: r.get(1)?,
                klartext: r.get(2)?,
                menge: r.get(3)?,
                einzelpreis: r.get(4)?,
                pos_wert: r.get(5)?,
                eur_wert: r.get(6)?,
            })
        })
        .map_err(|e| e.to_string())?;
    let mut out = Vec::new();
    for row in rows {
        out.push(row.map_err(|e| e.to_string())?);
    }
    Ok(out)
}

// ---------------------------------------------------------------------------
// Natursprache-Assistent: Frage -> Claude erzeugt read-only SQL + Chart-Spec
// -> Rust prüft (nur SELECT) und führt aus -> Frontend rendert.
// ---------------------------------------------------------------------------

#[derive(Serialize)]
struct AiAntwort {
    frage: String,
    sql: String,
    chart: String,             // kpi | bar | table
    label: Option<String>,     // Spaltenname für Kategorie
    value: Option<String>,     // Spaltenname für Wert
    titel: String,
    erklaerung: String,
    columns: Vec<String>,
    rows: Vec<Vec<serde_json::Value>>,
}

fn read_api_key() -> Result<String, String> {
    let home = std::env::var("HOME").map_err(|_| "HOME nicht gesetzt".to_string())?;
    let path = format!("{home}/.ssh/anthropic_api_key");
    let content =
        std::fs::read_to_string(&path).map_err(|e| format!("Key-Datei {path}: {e}"))?;
    content
        .lines()
        .map(|l| l.trim())
        .find(|l| l.starts_with("sk-ant-"))
        .map(|s| s.to_string())
        .ok_or_else(|| format!("kein sk-ant-Key in {path}"))
}

fn db_schema(con: &Connection) -> Result<String, String> {
    let mut stmt = con
        .prepare(
            "SELECT sql FROM sqlite_master \
             WHERE type IN ('table','view') AND sql IS NOT NULL \
             AND name NOT LIKE 'sqlite_%' ORDER BY type DESC, name",
        )
        .map_err(|e| e.to_string())?;
    let rows = stmt
        .query_map([], |r| r.get::<_, String>(0))
        .map_err(|e| e.to_string())?;
    let mut out = String::new();
    for r in rows {
        out.push_str(&r.map_err(|e| e.to_string())?);
        out.push_str(";\n");
    }
    Ok(out)
}

fn is_read_only(sql: &str) -> bool {
    let up = sql.trim().to_uppercase();
    if !(up.starts_with("SELECT") || up.starts_with("WITH")) {
        return false;
    }
    // kein Statement-Trenner (nur SELECT), keine schreibenden Schlüsselwörter
    if sql.trim_end().trim_end_matches(';').contains(';') {
        return false;
    }
    const VERBOTEN: [&str; 9] = [
        "INSERT ", "UPDATE ", "DELETE ", "DROP ", "ALTER ", "CREATE ", "ATTACH ",
        "PRAGMA ", "REPLACE ",
    ];
    !VERBOTEN.iter().any(|w| up.contains(w))
}

fn cell_to_json(v: rusqlite::types::ValueRef) -> serde_json::Value {
    use rusqlite::types::ValueRef;
    match v {
        ValueRef::Null => serde_json::Value::Null,
        ValueRef::Integer(n) => serde_json::Value::from(n),
        ValueRef::Real(f) => serde_json::Value::from(f),
        ValueRef::Text(t) => serde_json::Value::from(String::from_utf8_lossy(t).to_string()),
        ValueRef::Blob(_) => serde_json::Value::from("<blob>"),
    }
}

const SYSTEM_TMPL: &str = "Du übersetzt deutsche Fragen in **eine** SQLite-Abfrage über die \
folgende Datenbank und wählst die beste Visualisierung. Antworte AUSSCHLIESSLICH mit einem \
JSON-Objekt, keine Erklärung davor/danach, keine Markdown-Zäune.\n\n\
Schema:\n{SCHEMA}\n\
Hinweise:\n\
- Nur eine read-only SELECT-Abfrage (ggf. mit WITH). Kein Semikolon, kein INSERT/UPDATE/DELETE/DDL.\n\
- Beträge liegen in Landeswährung; EUR = Betrag / COALESCE(wechselkurs,1). auftraege.kundenpreis_netto ist der Auftragswert.\n\
- v_kunde_360.umsatz_netto ist bereits EUR-normiert. gewerk/land/segment kommen aus der Anreicherung.\n\
- Kunden-Anzeigename ist kunden.name_roh (bzw. v_kunde_360.name_roh), NICHT name_1.\n\
- Für Rankings/Vergleiche LIMIT setzen (z.B. 10). Spaltennamen sprechend aliasen.\n\n\
JSON-Format:\n\
{\"sql\": \"<SELECT ...>\", \"chart\": \"kpi|bar|table\", \"label\": \"<Spalte für Kategorie oder null>\", \
\"value\": \"<Spalte für Wert oder null>\", \"titel\": \"<kurzer Titel>\", \"erklaerung\": \"<1 Satz>\"}\n\
- chart=kpi: eine Kennzahl (value = die Wertspalte). chart=bar: label + value. chart=table: sonst.";

fn frage_ai_inner(frage: String, modell: String, thinking: bool) -> Result<AiAntwort, String> {
    let key = read_api_key()?;
    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
    let schema = db_schema(&con)?;
    let system = SYSTEM_TMPL.replace("{SCHEMA}", &schema);

    // Modell auf erlaubte Werte begrenzen
    let modell = match modell.as_str() {
        "claude-opus-4-8" | "claude-sonnet-5" | "claude-haiku-4-5" => modell,
        _ => "claude-sonnet-5".to_string(),
    };
    // Thinking-Konfiguration je Modell; bei Thinking mehr Output-Budget
    let (thinking_cfg, max_tokens) = if !thinking {
        (serde_json::json!({"type": "disabled"}), 1024)
    } else if modell.starts_with("claude-haiku") {
        (serde_json::json!({"type": "enabled", "budget_tokens": 2000}), 4096)
    } else {
        (serde_json::json!({"type": "adaptive"}), 4096)
    };

    let body = serde_json::json!({
        "model": modell,
        "max_tokens": max_tokens,
        "thinking": thinking_cfg,
        "system": system,
        "messages": [{"role": "user", "content": frage}]
    });

    let client = reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(60))
        .build()
        .map_err(|e| e.to_string())?;
    let resp = client
        .post("https://api.anthropic.com/v1/messages")
        .header("x-api-key", key)
        .header("anthropic-version", "2023-06-01")
        .header("content-type", "application/json")
        .json(&body)
        .send()
        .map_err(|e| format!("API-Aufruf fehlgeschlagen: {e}"))?;

    let status = resp.status();
    let json: serde_json::Value = resp.json().map_err(|e| e.to_string())?;
    if !status.is_success() {
        return Err(format!("API {status}: {}", json));
    }
    // Im Thinking-Modus steht ein thinking-Block vor dem text-Block -> gezielt den text-Block nehmen
    let text = json["content"]
        .as_array()
        .and_then(|blocks| {
            blocks
                .iter()
                .find(|b| b["type"] == "text")
                .and_then(|b| b["text"].as_str())
        })
        .ok_or_else(|| format!("kein Text in API-Antwort: {json}"))?;

    // JSON-Objekt aus dem Text herausschneiden (erstes { bis letztes })
    let start = text.find('{').ok_or("kein JSON in der Antwort")?;
    let end = text.rfind('}').ok_or("kein JSON in der Antwort")?;
    let spec: serde_json::Value =
        serde_json::from_str(&text[start..=end]).map_err(|e| format!("JSON-Parse: {e}"))?;

    let sql = spec["sql"].as_str().unwrap_or("").trim().to_string();
    if !is_read_only(&sql) {
        return Err(format!("Abfrage abgelehnt (nicht read-only): {sql}"));
    }
    let chart = spec["chart"].as_str().unwrap_or("table").to_string();
    let label = spec["label"].as_str().map(|s| s.to_string());
    let value = spec["value"].as_str().map(|s| s.to_string());
    let titel = spec["titel"].as_str().unwrap_or("").to_string();
    let erklaerung = spec["erklaerung"].as_str().unwrap_or("").to_string();

    let mut stmt = con.prepare(&sql).map_err(|e| format!("SQL-Fehler: {e}"))?;
    let columns: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
    let ncol = columns.len();
    let rows_iter = stmt
        .query_map([], |r| {
            let mut row = Vec::with_capacity(ncol);
            for i in 0..ncol {
                row.push(cell_to_json(r.get_ref(i)?));
            }
            Ok(row)
        })
        .map_err(|e| e.to_string())?;
    let mut rows = Vec::new();
    for r in rows_iter {
        rows.push(r.map_err(|e| e.to_string())?);
    }

    Ok(AiAntwort {
        frage,
        sql,
        chart,
        label,
        value,
        titel,
        erklaerung,
        columns,
        rows,
    })
}

#[tauri::command]
async fn frage_ai(frage: String, modell: String, thinking: bool) -> Result<AiAntwort, String> {
    tauri::async_runtime::spawn_blocking(move || frage_ai_inner(frage, modell, thinking))
        .await
        .map_err(|e| e.to_string())?
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
        .invoke_handler(tauri::generate_handler![
            list_kunden_360,
            list_auftraege,
            list_positionen,
            frage_ai
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}


wc -l /home/marcuh/projects/08-07/src/App.vue /home/marcuh/projects/08-07/src/catalog.ts /home/marcuh/projects/08-07/src/main.ts

Show line counts of frontend files


    1536 /home/marcuh/projects/08-07/src/App.vue
      30 /home/marcuh/projects/08-07/src/catalog.ts
       4 /home/marcuh/projects/08-07/src/main.ts
    1570 total


1
409
410	<template>
411
412
413	      <button
414	        class="tab"
415	        :class="{ on: activeTab === 'data' }"
416	        @click="activeTab = 'data'"
417	      >
418	        Data
419	      </button>
420	      <button
421	        class="tab"
422	        :class="{ on: activeTab === 'kpis' }"
423	        @click="activeTab = 'kpis'"
424	      >
425	        KPIs
426	      </button>
427	      <button
428	        class="tab"
429	        :class="{ on: activeTab === 'fragen' }"
430	        @click="activeTab = 'fragen'"
431	      >
432	        Fragen
433	      </button>
434
435

436
{{ activeTab === "kpis" ? "KPIs" : "Kundenstamm" }}

437	      geliefert & angereichert ·
439	        {{ gefiltert.length }}<template v-if="gefiltert.length !== kunden.length">/{{ kunden.length }}</template>
440	        Kunden · {{ bestaetigtCount }} bestätigt
442

443

444
445	        <svg class="trichter" viewBox="0 0 24 24" aria-hidden="true">
446	          <path d="M3 5h18l-7 8v6l-4 2v-8z" />
447	        </svg>
448	        <select v-model="filterGewerk" class="filter" title="Branche filtern">
449	          <option value="">Alle Branchen</option>
450	          <option v-for="o in gewerkOptionen" :key="o.wert" :value="o.wert">
451	            {{ o.label }}
452	          </option>
453	        </select>
454
455
456	        <svg class="trichter" viewBox="0 0 24 24" aria-hidden="true">
457	          <path d="M3 5h18l-7 8v6l-4 2v-8z" />
458	        </svg>
459	        <select v-model="filterLand" class="filter" title="Land filtern">
460	          <option value="">Alle Länder</option>
461	          <option v-for="o in landOptionen" :key="o.wert" :value="o.wert">
462	            {{ o.label }}
463	          </option>
464	        </select>
465
466	      <input
467	        v-model="suche"
468	        class="suche"
469	        type="search"
470	        placeholder="Suche Name, Ort, Branche…"
471	      />
472
473	        <select v-model="sortFeld" class="sort" title="Sortierfeld">
474	          <option v-for="o in sortOptionen" :key="o.feld" :value="o.feld">
475	            Sortierung: {{ o.label }}
476	          </option>
477	        </select>
478	        <button class="dir" @click="sortAuf = !sortAuf" :title="sortAuf ? 'aufsteigend' : 'absteigend'">
479	          {{ sortAuf ? "↑" : "↓" }}
480	        </button>
481
482

483
484
485
{{ fehler }}

486
487
488

495

496

497
{{ k.name_roh }}

498	          ✓
499	          ?
500

501

502	          geliefert
503
{{ k.strasse }}

504
{{ [k.plz, k.ort].filter(Boolean).join(" ") }}{{ k.land ? " · " + k.land : "" }}

505

506	            {{ k.n_auftraege }} {{ k.n_auftraege === 1 ? "Auftrag" : "Aufträge" }}
507	            · {{ eur.format(k.umsatz_netto) }}
508

509

510

511
512

513	        angereichert
514

515	          {{ k.gewerk }}
516	          {{ k.segment }}
517	          {{ k.mitarbeiter_kl }} MA
518	          gegr. {{ k.gruendungsjahr }}
519

520
{{ k.branche_text }}

521

522

523	          {{ Math.round(k.konfidenz * 100) }}%
524	          {{ k.quelle_primaer }}
525

526	        {{ k.website }}
527

528
529

530	        Aufträge ({{ (auftraegeByKunde.get(k.kunde_id) ?? []).length }})
531

532

539	            {{ a.datum ?? "—" }}
540
541	              <svg
542	                class="fzgicon"
543	                viewBox="0 0 24 24"
544	                fill="none"
545	                stroke="currentColor"
546	                stroke-width="1.5"
547	                stroke-linecap="round"
548	                stroke-linejoin="round"
549	                v-html="fzgIcon(a.fzg_klasse)"
550	                :aria-label="a.fzg_klasse ?? 'unbekannt'"
551	              ></svg>
552	              {{ fzgLabel(a) }}
553
554	            {{ fmtCur(a.umsatz_netto, a.waehrung, 0) }}
555

556

557

558

559
560
561

562

563

564
{{ kpis.nKunden }}

565
Kunden

566

567

568
{{ kpis.nAuftraege }}

569
Aufträge

570

571

572
{{ eur.format(kpis.umsatz) }}

573
Umsatz (EUR-normiert)

574

575

576
{{ eur.format(kpis.avg) }}

577
Ø Auftragswert

578

579

580
{{ kpis.ordersPct }}%

581
Auftrag (vs. Angebot)

582

583

584
{{ kpis.bestaetigtPct }}%

585
Branche bestätigt

586

587

588
589

590

591
{{ p.titel }}

592

593

594	            {{ panelLabel(r.key, p.land) }}
595
596	            {{ r.anzahl }}
597	            {{ eur.format(r.umsatz) }}
598

599

600

601

602

603
604

605

606
607	        Modell
608	        <select v-model="aiModell" class="modellsel">
609	          <option v-for="m in aiModelle" :key="m.id" :value="m.id">{{ m.label }}</option>
610	        </select>
611
612
613	        <input type="checkbox" v-model="aiThinking" />
614	        Thinking
615
616	      <input
617	        v-model="aiFrage"
618	        class="frageinput"
619	        type="text"
620	        placeholder="Frag die Daten … z. B. Umsatz je Gewerk, oder Top 10 Kunden nach Umsatz"
621	        @keyup.enter="fragen()"
622	      />
623	      <button class="fragebtn" @click="fragen()" :disabled="aiLaden">
624	        {{ aiLaden ? "…" : "Fragen" }}
625	      </button>
626

627

628	      <button
629	        v-for="b in aiBeispiele"
630	        :key="b"
631	        class="beispielchip"
632	        @click="fragen(b)"
633	      >
634	        {{ b }}
635	      </button>
636

637
638
{{ aiFehler }}

639
640

641
{{ aiAntwort.titel || aiAntwort.frage }}

642
{{ aiAntwort.erklaerung }}

643
644
{{ aiKpi }}

645
646

647

648	          {{ r.label }}
649
650	          {{ numFmt.format(r.value) }}
651

652

653
654

655

656

657
{{ c }}

658

659

660

661	              {{ zelle(cell) }}
662

663

664

665

666
667	      <button class="sqltoggle" @click="aiSqlOffen = !aiSqlOffen">
668	        {{ aiSqlOffen ? "SQL ausblenden" : "SQL anzeigen" }}
669	      </button>
670
{{ aiAntwort.sql }}

671

672

673
674	  <transition name="fade">
675

676

677
678

679
Stückliste

680

681	              {{ offenerAuftrag.auftrag_id.replace(/^export_/, "") }}
682	              {{ offenerAuftrag.datum ?? "—" }}
683	              {{ kundeById.get(offenerAuftrag.kunde_id)?.name_roh }}
684
685	                <svg
686	                  class="fzgicon"
687	                  viewBox="0 0 24 24"
688	                  fill="none"
689	                  stroke="currentColor"
690	                  stroke-width="1.5"
691	                  stroke-linecap="round"
692	                  stroke-linejoin="round"
693	                  v-html="fzgIcon(offenerAuftrag.fzg_klasse)"
694	                ></svg>
695	                {{ fzgLabel(offenerAuftrag) }}
696
697	              {{ offenerAuftrag.quotation_state === "order" ? "Auftrag" : offenerAuftrag.quotation_state }}
702	              {{ offenerAuftrag.waehrung }}
703

704

705	          <button class="dx" @click="closeAuftrag" aria-label="Schließen">✕</button>
706
707
708

709
lädt…

710
{{ fehlerPos }}

711

712

713

714	                #
715	                Bezeichnung
716	                Artikel-Nr.
717	                Menge
718	                Einzelpreis
719	                Wert
720	                EUR
721

722

723

724

725	                {{ idx + 1 }}
726
727	                  {{ p.klartext }}
728	                  {{ p.modul_id }}
729
730	                {{ p.artikelnummer ?? "—" }}
731	                {{ p.menge }}
732	                {{ fmtCur(p.einzelpreis, offenerAuftrag!.waehrung) }}
733	                {{ fmtCur(p.pos_wert, offenerAuftrag!.waehrung) }}
734	                {{ eur2.format(p.eur_wert) }}
735

736

737

738

739	                Summe Positionen ({{ positionen.length }})
740	                {{ fmtCur(posSumme, offenerAuftrag!.waehrung) }}
741	                {{ eur2.format(posSummeEur) }}
742

743

744
745	                  Kurs: 1 EUR = {{ kursFmt.format(offenerAuftrag!.wechselkurs!) }}
746	                  {{ offenerAuftrag!.waehrung }}
747	                  · Abschlusstag {{ offenerAuftrag!.kurs_datum }} · EZB-Referenzkurs
748
749

750

751

752

753

754

755	  </transition>
756	</template>
757
758
1505
1506
1537

<template>

      <button
        class="tab"
        :class="{ on: activeTab === 'data' }"
        @click="activeTab = 'data'"
      >
        Data
      </button>
      <button
        class="tab"
        :class="{ on: activeTab === 'kpis' }"
        @click="activeTab = 'kpis'"
      >
        KPIs
      </button>
      <button
        class="tab"
        :class="{ on: activeTab === 'fragen' }"
        @click="activeTab = 'fragen'"
      >
        Fragen
      </button>

{{ activeTab === "kpis" ? "KPIs" : "Kundenstamm" }}

      geliefert & angereichert ·
        {{ gefiltert.length }}<template v-if="gefiltert.length !== kunden.length">/{{ kunden.length }}</template>
        Kunden · {{ bestaetigtCount }} bestätigt

        <svg class="trichter" viewBox="0 0 24 24" aria-hidden="true">
          <path d="M3 5h18l-7 8v6l-4 2v-8z" />
        </svg>
        <select v-model="filterGewerk" class="filter" title="Branche filtern">
          <option value="">Alle Branchen</option>
          <option v-for="o in gewerkOptionen" :key="o.wert" :value="o.wert">
            {{ o.label }}
          </option>
        </select>

        <svg class="trichter" viewBox="0 0 24 24" aria-hidden="true">
          <path d="M3 5h18l-7 8v6l-4 2v-8z" />
        </svg>
        <select v-model="filterLand" class="filter" title="Land filtern">
          <option value="">Alle Länder</option>
          <option v-for="o in landOptionen" :key="o.wert" :value="o.wert">
            {{ o.label }}
          </option>
        </select>

      <input
        v-model="suche"
        class="suche"
        type="search"
        placeholder="Suche Name, Ort, Branche…"
      />

        <select v-model="sortFeld" class="sort" title="Sortierfeld">
          <option v-for="o in sortOptionen" :key="o.feld" :value="o.feld">
            Sortierung: {{ o.label }}
          </option>
        </select>
        <button class="dir" @click="sortAuf = !sortAuf" :title="sortAuf ? 'aufsteigend' : 'absteigend'">
          {{ sortAuf ? "↑" : "↓" }}
        </button>

{{ fehler }}

{{ k.name_roh }}

          ✓
          ?

          geliefert

{{ k.strasse }}

{{ [k.plz, k.ort].filter(Boolean).join(" ") }}{{ k.land ? " · " + k.land : "" }}

            {{ k.n_auftraege }} {{ k.n_auftraege === 1 ? "Auftrag" : "Aufträge" }}
            · {{ eur.format(k.umsatz_netto) }}

        angereichert

          {{ k.gewerk }}
          {{ k.segment }}
          {{ k.mitarbeiter_kl }} MA
          gegr. {{ k.gruendungsjahr }}

{{ k.branche_text }}

          {{ Math.round(k.konfidenz * 100) }}%
          {{ k.quelle_primaer }}

        {{ k.website }}

        Aufträge ({{ (auftraegeByKunde.get(k.kunde_id) ?? []).length }})

            {{ a.datum ?? "—" }}

              <svg
                class="fzgicon"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="1.5"
                stroke-linecap="round"
                stroke-linejoin="round"
                v-html="fzgIcon(a.fzg_klasse)"
                :aria-label="a.fzg_klasse ?? 'unbekannt'"
              ></svg>
              {{ fzgLabel(a) }}

            {{ fmtCur(a.umsatz_netto, a.waehrung, 0) }}

{{ kpis.nKunden }}

Kunden

{{ kpis.nAuftraege }}

Aufträge

{{ eur.format(kpis.umsatz) }}

Umsatz (EUR-normiert)

{{ eur.format(kpis.avg) }}

Ø Auftragswert

{{ kpis.ordersPct }}%

Auftrag (vs. Angebot)

{{ kpis.bestaetigtPct }}%

Branche bestätigt

{{ p.titel }}

            {{ panelLabel(r.key, p.land) }}

            {{ r.anzahl }}
            {{ eur.format(r.umsatz) }}

        Modell
        <select v-model="aiModell" class="modellsel">
          <option v-for="m in aiModelle" :key="m.id" :value="m.id">{{ m.label }}</option>
        </select>

        <input type="checkbox" v-model="aiThinking" />
        Thinking

      <input
        v-model="aiFrage"
        class="frageinput"
        type="text"
        placeholder="Frag die Daten … z. B. Umsatz je Gewerk, oder Top 10 Kunden nach Umsatz"
        @keyup.enter="fragen()"
      />
      <button class="fragebtn" @click="fragen()" :disabled="aiLaden">
        {{ aiLaden ? "…" : "Fragen" }}
      </button>

      <button
        v-for="b in aiBeispiele"
        :key="b"
        class="beispielchip"
        @click="fragen(b)"
      >
        {{ b }}
      </button>

{{ aiFehler }}

{{ aiAntwort.titel || aiAntwort.frage }}

{{ aiAntwort.erklaerung }}

{{ aiKpi }}

          {{ r.label }}

          {{ numFmt.format(r.value) }}

{{ c }}

              {{ zelle(cell) }}

      <button class="sqltoggle" @click="aiSqlOffen = !aiSqlOffen">
        {{ aiSqlOffen ? "SQL ausblenden" : "SQL anzeigen" }}
      </button>

{{ aiAntwort.sql }}

  <transition name="fade">

Stückliste

              {{ offenerAuftrag.auftrag_id.replace(/^export_/, "") }}
              {{ offenerAuftrag.datum ?? "—" }}
              {{ kundeById.get(offenerAuftrag.kunde_id)?.name_roh }}

                <svg
                  class="fzgicon"
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  stroke-width="1.5"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                  v-html="fzgIcon(offenerAuftrag.fzg_klasse)"
                ></svg>
                {{ fzgLabel(offenerAuftrag) }}

              {{ offenerAuftrag.quotation_state === "order" ? "Auftrag" : offenerAuftrag.quotation_state }}
              {{ offenerAuftrag.waehrung }}

          <button class="dx" @click="closeAuftrag" aria-label="Schließen">✕</button>

lädt…

{{ fehlerPos }}

                #
                Bezeichnung
                Artikel-Nr.
                Menge
                Einzelpreis
                Wert
                EUR

                {{ idx + 1 }}

                  {{ p.klartext }}
                  {{ p.modul_id }}

                {{ p.artikelnummer ?? "—" }}
                {{ p.menge }}
                {{ fmtCur(p.einzelpreis, offenerAuftrag!.waehrung) }}
                {{ fmtCur(p.pos_wert, offenerAuftrag!.waehrung) }}
                {{ eur2.format(p.eur_wert) }}

                Summe Positionen ({{ positionen.length }})
                {{ fmtCur(posSumme, offenerAuftrag!.waehrung) }}
                {{ eur2.format(posSummeEur) }}

                  Kurs: 1 EUR = {{ kursFmt.format(offenerAuftrag!.wechselkurs!) }}
                  {{ offenerAuftrag!.waehrung }}
                  · Abschlusstag {{ offenerAuftrag!.kurs_datum }} · EZB-Referenzkurs

  </transition>
</template>


1	// Kanonische Kataloge (Keymap) für Filter und Anzeige.
2	// gewerk: kontrolliertes Vokabular; muss zu den Werten aus der Anreicherung passen.
3
4	export const GEWERKE = [
5	  "Kfz",
6	  "SHK/HLK",
7	  "Bau-Dach",
8	  "Elektro",
9	  "Holz-Zimmerei",
10	  "Fördertechnik",
11	  "Industrie",
12	  "Handel",
13	  "öffentlich",
14	  "Sonstige",
15	] as const;
16
17	// ISO-2 -> deutscher Klarname
18	export const LAND_NAMES: Record<string, string> = {
19	  DE: "Deutschland",
20	  AT: "Österreich",
21	  CH: "Schweiz",
22	  NO: "Norwegen",
23	  FR: "Frankreich",
24	  ES: "Spanien",
25	  IT: "Italien",
26	  PL: "Polen",
27	};
28
29	// Reihenfolge der Länder im Filter
30	export const LAENDER = ["DE", "AT", "CH", "NO", "FR", "ES", "IT", "PL"] as const;
31

// Kanonische Kataloge (Keymap) für Filter und Anzeige.
// gewerk: kontrolliertes Vokabular; muss zu den Werten aus der Anreicherung passen.

export const GEWERKE = [
  "Kfz",
  "SHK/HLK",
  "Bau-Dach",
  "Elektro",
  "Holz-Zimmerei",
  "Fördertechnik",
  "Industrie",
  "Handel",
  "öffentlich",
  "Sonstige",
] as const;

// ISO-2 -> deutscher Klarname
export const LAND_NAMES: Record<string, string> = {
  DE: "Deutschland",
  AT: "Österreich",
  CH: "Schweiz",
  NO: "Norwegen",
  FR: "Frankreich",
  ES: "Spanien",
  IT: "Italien",
  PL: "Polen",
};

// Reihenfolge der Länder im Filter
export const LAENDER = ["DE", "AT", "CH", "NO", "FR", "ES", "IT", "PL"] as const;


Edit

fn frage_ai_inner(frage: String, modell: String, thinking: bool) -> Result<AiAntwort, String> {
    let key = read_api_key()?;
    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
    let schema = db_schema(&con)?;
    let system = SYSTEM_TMPL.replace("{SCHEMA}", &schema);

    // Modell auf erlaubte Werte begrenzen
    let modell = match modell.as_str() {
        "claude-opus-4-8" | "claude-sonnet-5" | "claude-haiku-4-5" => modell,
        _ => "claude-sonnet-5".to_string(),
    };
    // Thinking-Konfiguration je Modell; bei Thinking mehr Output-Budget
    let (thinking_cfg, max_tokens) = if !thinking {
        (serde_json::json!({"type": "disabled"}), 1024)
    } else if modell.starts_with("claude-haiku") {
        (serde_json::json!({"type": "enabled", "budget_tokens": 2000}), 4096)
    } else {
        (serde_json::json!({"type": "adaptive"}), 4096)
    };

    let body = serde_json::json!({
        "model": modell,
        "max_tokens": max_tokens,
        "thinking": thinking_cfg,
        "system": system,
        "messages": [{"role": "user", "content": frage}]
    });

    let client = reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(60))
        .build()
        .map_err(|e| e.to_string())?;
    let resp = client
        .post("https://api.anthropic.com/v1/messages")
        .header("x-api-key", key)
        .header("anthropic-version", "2023-06-01")
        .header("content-type", "application/json")
        .json(&body)
        .send()
        .map_err(|e| format!("API-Aufruf fehlgeschlagen: {e}"))?;

    let status = resp.status();
    let json: serde_json::Value = resp.json().map_err(|e| e.to_string())?;
    if !status.is_success() {
        return Err(format!("API {status}: {}", json));
    }
    // Im Thinking-Modus steht ein thinking-Block vor dem text-Block -> gezielt den text-Block nehmen
    let text = json["content"]
        .as_array()
        .and_then(|blocks| {
            blocks
                .iter()
                .find(|b| b["type"] == "text")
                .and_then(|b| b["text"].as_str())
        })
        .ok_or_else(|| format!("kein Text in API-Antwort: {json}"))?;

    // JSON-Objekt aus dem Text herausschneiden (erstes { bis letztes })
    let start = text.find('{').ok_or("kein JSON in der Antwort")?;
    let end = text.rfind('}').ok_or("kein JSON in der Antwort")?;
    let spec: serde_json::Value =
        serde_json::from_str(&text[start..=end]).map_err(|e| format!("JSON-Parse: {e}"))?;

// Ein Claude-Aufruf (system + eine User-Nachricht), gibt den text-Block zurück.
// Im Thinking-Modus steht ein thinking-Block vor dem text-Block -> gezielt den text-Block nehmen.
fn claude_text(
    modell: &str,
    thinking: bool,
    max_tokens_plain: u32,
    system: &str,
    user: &str,
) -> Result<String, String> {
    let key = read_api_key()?;
    // Thinking-Konfiguration je Modell; bei Thinking mehr Output-Budget
    let (thinking_cfg, max_tokens) = if !thinking {
        (serde_json::json!({"type": "disabled"}), max_tokens_plain)
    } else if modell.starts_with("claude-haiku") {
        (serde_json::json!({"type": "enabled", "budget_tokens": 2000}), 4096)
    } else {
        (serde_json::json!({"type": "adaptive"}), 4096)
    };

    let body = serde_json::json!({
        "model": modell,
        "max_tokens": max_tokens,
        "thinking": thinking_cfg,
        "system": system,
        "messages": [{"role": "user", "content": user}]
    });

    let client = reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(60))
        .build()
        .map_err(|e| e.to_string())?;
    let resp = client
        .post("https://api.anthropic.com/v1/messages")
        .header("x-api-key", key)
        .header("anthropic-version", "2023-06-01")
        .header("content-type", "application/json")
        .json(&body)
        .send()
        .map_err(|e| format!("API-Aufruf fehlgeschlagen: {e}"))?;

    let status = resp.status();
    let json: serde_json::Value = resp.json().map_err(|e| e.to_string())?;
    if !status.is_success() {
        return Err(format!("API {status}: {}", json));
    }
    json["content"]
        .as_array()
        .and_then(|blocks| {
            blocks
                .iter()
                .find(|b| b["type"] == "text")
                .and_then(|b| b["text"].as_str())
        })
        .map(|s| s.to_string())
        .ok_or_else(|| format!("kein Text in API-Antwort: {json}"))
}

// JSON-Objekt aus dem Antworttext herausschneiden (erstes { bis letztes })
fn json_objekt(text: &str) -> Result<serde_json::Value, String> {
    let start = text.find('{').ok_or("kein JSON in der Antwort")?;
    let end = text.rfind('}').ok_or("kein JSON in der Antwort")?;
    serde_json::from_str(&text[start..=end]).map_err(|e| format!("JSON-Parse: {e}"))
}

fn frage_ai_inner(frage: String, modell: String, thinking: bool) -> Result<AiAntwort, String> {
    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
    let schema = db_schema(&con)?;
    let system = SYSTEM_TMPL.replace("{SCHEMA}", &schema);

    // Modell auf erlaubte Werte begrenzen
    let modell = match modell.as_str() {
        "claude-opus-4-8" | "claude-sonnet-5" | "claude-haiku-4-5" => modell,
        _ => "claude-sonnet-5".to_string(),
    };

    let text = claude_text(&modell, thinking, 1024, &system, &frage)?;
    let spec = json_objekt(&text)?;