Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ This file records what changes **in the product** – process and session state
architecture map (#115).

### Added
- Public case study page `/case-study` (no login, static content, no data access): problem and target group,
what the AI does and what the person decides, the four steps up to the exactly-once ERP export, screenshots of
the prepared samples and the limits; linked from the start page for visitors without an account (#75).
- Evidence page `docs/product/evidence.md`: eval accuracy per key field (reproducible with `pnpm evals`), the
measured state of both sample requests, three key decisions and the limits; handling time marked as not yet
measured (#76).
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ e-mails and PDF/Excel/Word attachments, review every value **beside its source**
approved request **exactly once** to an ERP.

> **Status:** pilot scope built and released as [v0.1.0](https://github.com/Fluory/RequestFlow/releases/tag/v0.1.0)
> (2026-09-28); acceptance pending ([roadmap](docs/product/roadmap.md), M2). A synthetic showcase runs on Vercel
> for invited visitors.
> (2026-09-28); acceptance pending ([roadmap](docs/product/roadmap.md), M2). A synthetic showcase runs on Vercel:
> the **[case study](https://requestflow-doc-edit.vercel.app/case-study)** is public (German, no login), the
> [app](https://requestflow-doc-edit.vercel.app) itself is open to invited visitors.
> **Reference project:** built like a real customer engagement for a mid-sized machine-building
> company; the customer is fictional and **all data in this repository is synthetic**.

Expand All @@ -24,6 +25,10 @@ upload (.eml/.msg/PDF/XLSX/DOCX) → parse → extract with evidence → verify
- **Tenant isolation:** repository scoping plus forced PostgreSQL row-level security per company.
- **Measurable AI quality:** an eval set with per-field metrics gates prompt and model changes.

![Review page of the prepared sample "Werk Ost": the delivery date was given only as a calendar week, so it is marked "unsicher"; the cited line is highlighted in the source on the right](public/case-study/review.png)

*Review beside the source – prepared sample, synthetic data.*

## What runs today

Each line is a merged PR; details in the [CHANGELOG](CHANGELOG.md).
Expand All @@ -38,6 +43,7 @@ Each line is a merged PR; details in the [CHANGELOG](CHANGELOG.md).
- Request list led by the next action, with diagnosis, retries and reprocessing, paged (#43, #58, #95)
- Eval set of 15 synthetic cases as a CI gate (#41); correlated structured logs and health (#45, #56, #88)
- Showcase: prepared sample requests replayed from recorded AI answers (#83)
- Public case study `/case-study`: problem, flow, sample screenshots and limits – no login, no data access (#75)

## Architecture

Expand Down Expand Up @@ -89,3 +95,4 @@ green CI, and is never merged by its author. The process follows the fluory-syst
KI-gestützte Erfassung von Angebotsanfragen: Daten aus E-Mails und Anhängen extrahieren, jede
Angabe neben ihrer Fundstelle prüfen und freigegebene Anfragen genau einmal ans ERP übergeben.
Referenzprojekt mit fiktivem Kunden – alle Daten sind synthetisch.
Öffentliche Fallstudie: [requestflow-doc-edit.vercel.app/case-study](https://requestflow-doc-edit.vercel.app/case-study).
2 changes: 1 addition & 1 deletion docs/technical/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Every new file belongs to one of these modules – otherwise add the module here
| `observability` | `src/features/observability/` | logger, health, request-list ops data | `/api/health` | IDs only | no PII in logs | built: pino JSON logs with a fixed key set (IDs + codes only, tested on a full run), health (database, storage; informational AI-service reachability and queue backlog) |
| `db` | `src/db/`, deploy step `src/setup.ts` | Drizzle schema, migrations, DB roles | internal | – | migrations as owner role | built: roles check, schema `app`, default grants for `app_rw` |
| `config` | `src/config/` | typed runtime configuration, validated at start (zod); serverless drain budget | internal | secrets (in memory only) | errors name variables, never values | built |
| `app` | `src/app/` | Next.js routes and pages; composition root `src/app/_server/` (pool, storage client, serverless drain) | `/`, `/login`, `/signup`, `/invite`, `/requests`, `/requests/:id`, `/api/requests`, `/api/documents/:id`, `/api/auth/*`, `/api/health`, `/api/erp-mock/v1/quote-requests` (flag), `/api/jobs/drain` (`CRON_SECRET`) | – | calls module APIs only (dependency-cruiser); drain route: bearer secret, constant-time | partial: login, sign-up, invite, home, requests, review page, ERP mock route |
| `app` | `src/app/` (static assets: `public/`) | Next.js routes and pages; composition root `src/app/_server/` (pool, storage client, serverless drain) | `/`, `/case-study` (public, static), `/login`, `/signup`, `/invite`, `/requests`, `/requests/:id`, `/api/requests`, `/api/documents/:id`, `/api/auth/*`, `/api/health`, `/api/erp-mock/v1/quote-requests` (flag), `/api/jobs/drain` (`CRON_SECRET`) | – | calls module APIs only (dependency-cruiser); drain route: bearer secret, constant-time; `/case-study` reads no session and no data | partial: login, sign-up, invite, home, public case study, requests, review page, ERP mock route |
| AI service | `services/ai/` | docling parsing, extraction, grounding, evals | internal HTTP | confidential + personal (transient) | bearer token, stateless, no DB/storage access | built: eval runner + 15 weighted synthetic cases + replay gate in CI (#24); `POST /v1/extract` – EML, MSG (recursive attachments), PDF (text layer; scans via OCR with `AI_PDF_OCR=auto`), XLSX (rows), DOCX (paragraphs, table cells) → segments with stable locators, bounded OOXML/MSG parsing, 6 header fields + line items (schema v2, prompt `extract_v2`), normalisers (German numbers, units, dates, calendar weeks → at most `uncertain`), grounding verifier, bearer auth; Vertex adapter with recorded responses (live call unverified) |
| Contracts | `contracts/` | OpenAPI: AI service, ERP export | – | – | contract tests | built: `ai-service.openapi.yaml` (generated from the service), `erp-export.openapi.yaml` (hand-written); TS types + drift tests |

Expand Down
Binary file added public/case-study/exported.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/case-study/requests.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/case-study/review.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
142 changes: 142 additions & 0 deletions src/app/case-study.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* Public case study (#75): a reading page on the pilot design tokens (globals.css). Reading width ~70
* characters; screenshots framed like the app's cards; numbered steps because the flow is a real sequence.
*/
.case-study {
max-width: 960px;
gap: 40px;
}
.cs-hero {
display: flex;
flex-direction: column;
gap: 14px;
padding-top: 8px;
}
.cs-brand {
align-self: flex-start;
}
.cs-eyebrow {
font-family: var(--mono);
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
}
.case-study h1 {
font-size: clamp(26px, 4vw, 34px);
line-height: 1.2;
max-width: 28ch;
text-wrap: balance;
}
.cs-lede {
font-size: 17px;
line-height: 1.6;
color: var(--text-2);
max-width: 68ch;
}
.cs-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 20px;
}
.cs-note,
.cs-synthetic {
font-size: 14px;
color: var(--muted);
max-width: 68ch;
}
.cs-section {
display: flex;
flex-direction: column;
gap: 12px;
border-top: 1px solid var(--rule);
padding-top: 24px;
}
.cs-section h2 {
font-size: 19px;
}
.cs-section p,
.cs-section li {
line-height: 1.6;
max-width: 70ch;
}
.cs-section ul {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 6px;
}
.cs-split {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr));
gap: 24px 40px;
}
.cs-split > div {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.cs-steps {
margin: 0;
padding: 0;
list-style: none;
counter-reset: step;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr));
gap: 16px;
}
.cs-steps li {
counter-increment: step;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 14px;
line-height: 1.5;
}
.cs-steps li::before {
content: counter(step);
font-family: var(--mono);
font-size: 13px;
font-weight: 500;
color: var(--brand);
}
.cs-shots {
display: flex;
flex-direction: column;
gap: 28px;
}
.cs-shots figure {
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.cs-shots img {
width: 100%;
height: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.cs-shots figcaption {
font-size: 14px;
color: var(--text-2);
max-width: 70ch;
}
.cs-section .cs-links {
list-style: none;
padding-left: 0;
}
.cs-foot {
font-size: 13px;
color: var(--muted);
border-top: 1px solid var(--rule);
padding-top: 16px;
}
179 changes: 179 additions & 0 deletions src/app/case-study/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { Brand } from "@/app/_components/app-header";

// #75: public case study – static content only. No session lookup and no data access in this page; the
// auth checks of every other route stay where they are. Screenshots show the prepared samples (#71),
// synthetic data only.
export const metadata: Metadata = {
title: "Fallstudie – RequestFlow",
description: "Angebotsanfragen erfassen, jeden Wert neben seiner Quelle prüfen und genau einmal ans ERP übergeben – Referenzprojekt mit synthetischen Daten.",
};

const REPO = "https://github.com/Fluory/RequestFlow";
const EVIDENCE = `${REPO}/blob/main/docs/product/evidence.md`;
const ADR = `${REPO}/blob/main/docs/decisions/ADR-0001-pilot-architecture.md`;
const PROPOSAL = `${REPO}/blob/main/docs/product/pilot-vorschlag.md`;

const STEPS = [
{
title: "Hochladen",
text: "Die E-Mail mit ihren Anhängen (PDF, Excel, Word) wird hochgeladen. Eine doppelt eingereichte Anfrage wird erkannt, markiert und mit dem Original verknüpft – nie stillschweigend verworfen.",
},
{
title: "Erkennen mit Quelle",
text: "Ein KI-Dienst ohne Zugriff auf Datenbank und Ablage schlägt die Werte vor, jeden mit einem wörtlichen Zitat. Ein Prüfprogramm zählt einen Wert nur als „gefunden“, wenn es das Zitat an der genannten Stelle wiederfindet.",
},
{
title: "Prüfen und freigeben",
text: "Eine Person sieht jeden Wert neben seiner Quelle, korrigiert, was unsicher ist oder fehlt, und gibt die Anfrage frei.",
},
{
title: "Genau einmal ins ERP",
text: "Idempotenzschlüssel, eindeutige Export-Zeile und Zeilensperre verhindern, dass eine Anfrage zweimal ankommt – auch wenn eine Übergabe nach einem Fehler wiederholt wird.",
},
] as const;

const SHOTS = [
{
src: "/case-study/requests.png",
alt: "Anfrageliste mit zwei vorbereiteten Musterfällen: einer wartet auf Prüfung, einer ist exportiert",
caption: "Die Anfrageliste nennt je Anfrage den Kunden, was zu prüfen ist und den nächsten Schritt. Musterfälle sind als Beispiel gekennzeichnet.",
},
{
src: "/case-study/review.png",
alt: "Prüfansicht des Musterfalls „Werk Ost“: erkannte Angaben mit Status, daneben die Quelle mit markiertem Zitat",
caption: "Prüfansicht „Werk Ost“: Der Liefertermin steht nur als Kalenderwoche in der Mail und ist deshalb „unsicher“ – die Person sieht die markierte Stelle und entscheidet.",
},
{
src: "/case-study/exported.png",
alt: "Exportierter Musterfall „Pumpe P204“ mit ERP-Referenz",
caption: "Musterfall „Pumpe P204“: freigegeben und genau einmal an das ERP übergeben, mit der Referenz, die das ERP zurückgemeldet hat.",
},
] as const;

export default function CaseStudyPage() {
return (
<main className="case-study">
<header className="cs-hero">
<Brand className="cs-brand" />
<p className="cs-eyebrow">Fallstudie · Referenzprojekt</p>
<h1>Angebotsanfragen erfassen, jeden Wert neben seiner Quelle prüfen – und genau einmal ans ERP übergeben</h1>
<p className="cs-lede">
RequestFlow liest Anfragen aus E-Mails und ihren PDF-, Excel- und Word-Anhängen, legt jeden erkannten Wert
neben die Stelle im Dokument, aus der er stammt, und übergibt eine freigegebene Anfrage genau einmal an das
ERP. Gebaut wie ein echtes Kundenprojekt für einen mittelständischen Maschinenbauer: Der Kunde ist fiktiv,
alle Daten sind synthetisch.
</p>
<div className="cs-actions">
<Link href="/login" className="btn-link">
Live-App öffnen
</Link>
<a href={EVIDENCE}>Gemessene Nachweise</a>
<a href={REPO}>Quellcode auf GitHub</a>
</div>
<p className="cs-note">
Die Live-App ist auf Einladung zugänglich. Nach der Anmeldung führt der Hinweis „Nächster Schritt“ auf der
Startseite direkt zum vorbereiteten Musterfall.
</p>
</header>

<section className="cs-section" aria-labelledby="problem-heading">
<h2 id="problem-heading">Das Problem</h2>
<p>
Im Vertriebsinnendienst eines Maschinenbauers kommen Angebotsanfragen als E-Mail mit Anhängen: Zeichnungen
und Stücklisten als PDF, Positionslisten in Excel, Anschreiben in Word. Jemand überträgt Firma,
Ansprechpartner, gewünschten Liefertermin und jede Position mit Menge, Werkstoff und Maßen von Hand ins
ERP. Das kostet Zeit, Tippfehler fallen spät auf, und eine doppelt erfasste Anfrage erzeugt doppelte
Vorgänge.
</p>
<p>
<strong>Für wen:</strong> die Sachbearbeitung, die Anfragen erfasst und prüft, und die Teamleitung, die sehen
will, was auf Prüfung wartet.
</p>
</section>

<section className="cs-section cs-split" aria-label="Aufgabenteilung">
<div>
<h2>Was die KI macht</h2>
<ul>
<li>Sie schlägt Kopfdaten (Firma, Ansprechpartner, E-Mail, Telefon, Liefertermin, Zusatzanforderungen) und Positionen (Beschreibung, Menge, Einheit, Werkstoff, Maße) vor.</li>
<li>Zu jedem Wert liefert sie ein wörtliches Zitat und die Stelle im Dokument.</li>
<li>Nur wenn das Prüfprogramm das Zitat an dieser Stelle wiederfindet, gilt der Wert als „gefunden“. Alles andere ist „unsicher“ oder „fehlt“ und geht an eine Person.</li>
</ul>
</div>
<div>
<h2>Was die Person entscheidet</h2>
<ul>
<li>Sie sieht jeden Wert neben seiner Quelle; die zitierte Stelle ist markiert.</li>
<li>Sie korrigiert unsichere oder fehlende Angaben; die ursprüngliche Erkennung bleibt sichtbar.</li>
<li>Sie gibt die Anfrage frei. Ohne Freigabe geht nichts ans ERP.</li>
</ul>
</div>
</section>

<section className="cs-section" aria-labelledby="flow-heading">
<h2 id="flow-heading">Der Ablauf</h2>
<ol className="cs-steps">
{STEPS.map((step) => (
<li key={step.title}>
<strong>{step.title}</strong>
<span>{step.text}</span>
</li>
))}
</ol>
</section>

<section className="cs-section" aria-labelledby="sample-heading">
<h2 id="sample-heading">Der Musterfall im Bild</h2>
<p className="cs-synthetic">Synthetische Daten: vorbereitete Musterfälle mit einer einmal aufgezeichneten KI-Antwort.</p>
<div className="cs-shots">
{SHOTS.map((shot) => (
<figure key={shot.src}>
<Image src={shot.src} alt={shot.alt} width={1280} height={800} sizes="(max-width: 960px) 100vw, 920px" />
<figcaption>{shot.caption}</figcaption>
</figure>
))}
</div>
</section>

<section className="cs-section" aria-labelledby="limits-heading">
<h2 id="limits-heading">Was geprüft ist – und was nicht</h2>
<ul>
<li>Das Prüfprogramm belegt, woher ein Wert stammt, nicht, ob er fachlich so gemeint ist. Deshalb prüft eine Person jeden Wert mit seinem Zitat.</li>
<li>Zitiert ein Modell einen eingeschleusten Satz wörtlich, besteht er die Prüfung. Auch hier ist die Person die zweite Schicht.</li>
<li>Die Qualitätszahlen stammen aus 15 synthetischen Testfällen mit vorbereiteten Modellantworten: Sie messen die Prüfkette, nicht ein Live-Modell.</li>
<li>Die Musterfälle wurden einmal mit einem kostenlosen Modell aufgezeichnet, ausschließlich mit synthetischen Daten. Das ERP ist eine Simulation.</li>
<li>Eine Zeitersparnis ist noch nicht gemessen und wird deshalb nicht behauptet.</li>
</ul>
</section>

<section className="cs-section" aria-labelledby="more-heading">
<h2 id="more-heading">Nachweise und Entscheidungen</h2>
<ul className="cs-links">
<li>
<a href={EVIDENCE}>Gemessene Nachweise</a> – Genauigkeit je Feld, Stand der Musterfälle, Grenzen
</li>
<li>
<a href={ADR}>Architekturentscheidung ADR-0001</a> – elf Entscheidungen mit verworfenen Alternativen
</li>
<li>
<a href={PROPOSAL}>Pilotvorschlag an den (fiktiven) Kunden</a>
</li>
<li>
<a href={REPO}>Quellcode und Tests</a> – TypeScript-Monolith (Next.js, PostgreSQL mit erzwungener
Row-Level-Security, pg-boss) und zustandsloser Python-KI-Dienst (FastAPI, docling)
</li>
</ul>
<p>
<Link href="/login" className="btn-link">
Live-App öffnen
</Link>
</p>
</section>

<p className="cs-foot">Referenzprojekt · Kunde fiktiv · alle Daten synthetisch</p>
</main>
);
}
1 change: 1 addition & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import "@fontsource/ibm-plex-mono/latin-500.css";
import "./globals.css";
import "./components.css";
import "./review.css";
import "./case-study.css";

export const metadata: Metadata = {
title: "RequestFlow",
Expand Down
4 changes: 4 additions & 0 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export default async function HomePage() {
<p>
<Link href="/login">Anmelden</Link> · <Link href="/signup">Konto mit Einladung anlegen</Link>
</p>
{/* #75: visitors without an account find the public case study here. */}
<p>
<Link href="/case-study">So funktioniert RequestFlow – Fallstudie lesen</Link>
</p>
</div>
<p className="auth-foot">Pilot – alle Daten sind synthetisch.</p>
</div>
Expand Down
Loading
Loading