Vorgaenge in der WebUI: entscheiden und Stellungnahme anfordern (#689, Stufe 3)

- /api/cases (offen/alle), /api/cases/{id}/decide, /api/cases/{id}/statement, /api/decisions
- Ansicht "Vorgaenge": offene Faelle mit Verlauf je Ebene (Lauf-Links), Frist und
  "Mensch gerufen"; Reiter Beschluesse mit Reichweite; Zaehler in der Seitenleiste
- Konzept Abschnitt 5: Umsetzung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-25 09:53:25 +02:00
co-authored by Claude Opus 5.5
parent 36fe1ad2aa
commit 418a73015d
11 changed files with 600 additions and 2 deletions
+43
View File
@@ -475,6 +475,49 @@ Ohne Regeln läuft so etwas heiß. Vier, die nicht verhandelbar sind:
4. **Die Spitze ist endlich.** Was der Mensch nicht entscheidet, bleibt offen — sichtbar
offen, nicht stillschweigend versandet.
### Umsetzung (2026-09-25, #689)
- **Vorgang und Beschluss** sind eigene Datensätze (`Cases`, `Decisions`). Der Vorgang
selbst wandert nicht über das Brett. Jeder Halter bekommt eine eigene Aufgabe in seinen
Eingang, die Regeln führt `CaseDesk`.
- **Eskalation:** Das Werkzeug `Escalation` hat jeder Agent, ohne dass man es freischalten
muss. „Ich komme nicht weiter“ ist keine Berechtigung. Der Vorgang geht an die eigene
Leitung, eine Ebene je Schritt. Die Leitung hat drei Antworten:
- `decide`: mit Pflichtbegründung, gilt für den Bereich der Leitung
- `handle`: sie erledigt es selbst
- `forward`: sie reicht mit eigener Einschätzung weiter
Die Aufgabe, an der der Agent feststeckt, wartet (blockiert) und geht beim Abschluss
wieder an die Arbeit.
- **Erst nachsehen:** Vor jeder Eskalation zeigt das Werkzeug die Beschlüsse, die den
Agenten binden. Dieselbe Frage aus derselben Einheit schließt sich dem offenen Vorgang
an, statt einen zweiten zu eröffnen.
- **Konflikt:** Zuerst nimmt die Gegenseite einmal Stellung. Danach geht der Vorgang direkt
an die niedrigste gemeinsame Leitung. Die Leitungen dazwischen stehen als „vermerkt,
nicht befragt“ im Vorgang. Einen Konflikt kann man weder weiterreichen noch „selbst
erledigen“, er wird entschieden, und beide Seiten erfahren den Beschluss.
- **Damit es endet:**
- Es gibt einen Tagesdeckel je Einheit (`cases.escalationsPerUnitPerDay`, Vorgabe 10).
Darüber geht die Eskalation direkt an den Menschen, mit einem Vermerk.
- Nach der Frist (`cases.deadlineHours`, Vorgabe 4 Stunden) wird der Mensch gerufen,
einmal. Der Weckruf hat eine Anschlussstelle für den Kanal aus #644. Bis dahin
erscheint der Vorgang in der neuen Ansicht „Vorgänge“, mit einem Zähler in der
Seitenleiste.
- **Der Mensch** entscheidet dort mit Begründung (gilt für die ganze Instanz) oder fordert
von einer Seite eine Stellungnahme an. Das ist die einzige Bewegung nach unten, die den
Vorgang nicht schließt, und sie hat eine eigene Frist.
- **Stille Post (#697) und Berichtsweg (#715):**
- Jede Ebene hängt ihren Beitrag an, jeder Beitrag trägt seinen Lauf.
- Beim Halter kommt jeder Beitrag gerahmt als Daten an.
- Wer fremde Inhalte liest, eskaliert ohne eigenen Text (nur mit dem Verweis auf die
Aufgabe) und eröffnet keinen Konflikt mit eigenem Standpunkt.
- **Nebenbei behoben:** Der Scanner hat einen Status, den der Lauf selbst gesetzt hatte
(blockiert, abgebrochen), am Laufende mit „erledigt“ überschrieben. Damit liefen sowohl
das Eskalieren als auch der Meldeweg aus #715 ins Leere.
- **Offen:** #676 (Schemaauskunft der Datenbank) ist die Vorbedingung dafür, dass „ich finde
kein Feld“ eine Feststellung ist und keine Vermutung. Der Mechanismus steht, der
Anwendungsfall braucht noch #676.
## 6 — Die Entscheidungsinstanz an der Spitze
Der CEO-Agent aus dem Vorschlag ist eine gute Idee mit einer Bedingung: Er ist eine
+7
View File
@@ -61,6 +61,12 @@ public sealed class AppHost : IAsyncDisposable
public required ILoggerFactory LoggerFactory { get; init; }
public required ToolRegistry Tools { get; init; }
/// <summary>#689: Escalations, conflicts and decisions — read by the interfaces, acted on through the desk.</summary>
public Core.Cases.ICaseRepository? Cases { get; init; }
/// <summary>#689: The desk a human decides at; <c>null</c> without a task board.</summary>
public Core.Cases.ICaseDesk? CaseDesk => Engine?.CaseDesk;
/// <summary>
/// Zustandsspeicher und Audit-Log der Instanz. Nicht oeffentlich — sie gehoeren dem
/// Aufbau, nicht den Oberflaechen; gebraucht werden sie hier fuer die Sichtung
@@ -600,6 +606,7 @@ public sealed class AppHost : IAsyncDisposable
Board = board,
Usage = usage,
Transcripts = transcripts,
Cases = cases,
Insights = new RunInsights(audit, transcripts, board, staging,
agentId => instance.Agents.FirstOrDefault(a => a.AgentId == agentId) is { } agent
? engine.TranscriptRetentionFor(agent)
@@ -781,6 +781,68 @@ button.klein {
gap: 12px;
}
/* ─── #689: cases ─── */
.faelle-liste {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
}
.fall-plaketten {
margin-bottom: 12px;
}
/* The two answers of the human wrap below the subject on a narrow screen instead of squeezing it. */
.fall-karte .karte-kopf {
flex-wrap: wrap;
gap: 8px;
}
.fall-karte .karte-kopf h2 {
flex: 1 1 240px;
}
.fall-beim-menschen {
border-left: 3px solid var(--warnung);
}
.fall-verlauf-titel {
margin: 16px 0 8px;
font-size: 14px;
}
.fall-verlauf {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.fall-eintrag {
padding: 8px 12px;
border-left: 2px solid var(--divider);
background: var(--bg);
}
.fall-eintrag-decided,
.fall-eintrag-handled {
border-left-color: var(--akzent, var(--divider));
}
.fall-eintrag-called {
border-left-color: var(--warnung);
}
.fall-eintrag-text {
margin-top: 4px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* #715: marks a tool that brings outside text in — it changes the agent's reporting line. */
.werkzeug-fremd {
display: inline-block;
@@ -37,6 +37,7 @@ import { renderRunDetail } from "./views/run-detail.js";
import { renderPromptHistory } from "./views/prompt-history.js";
import { renderTeam, leaveTeam } from "./views/team.js";
import { renderOrganisation } from "./views/organisation.js";
import { renderCases, holeMenschenfaelle } from "./views/cases.js";
// ─── 1. Routen registrieren ───
@@ -57,6 +58,7 @@ registriereRoute("/team", () => { verlasseAnsicht(); return renderTeam(); });
registriereRoute("/organisation", () => { verlasseAnsicht(); return renderOrganisation(); });
registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); });
registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); });
registriereRoute("/cases", () => { verlasseAnsicht(); return renderCases(); });
registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); });
// #660: token usage sub-views and the prompt history
registriereRoute("/verbrauch/runs", () => { verlasseAnsicht(); return renderUsageRuns(); });
@@ -335,9 +337,19 @@ async function aktualisiereStagingZaehler() {
if (!holeAktiveInstanz()) {
const badge = document.getElementById("staging-badge");
if (badge) badge.hidden = true;
const faelle = document.getElementById("faelle-badge");
if (faelle) faelle.hidden = true;
return;
}
// #689: Cases that wait for a human are counted beside the approvals.
const faelleAnzahl = await holeMenschenfaelle();
const faelleBadge = document.getElementById("faelle-badge");
if (faelleBadge) {
faelleBadge.textContent = String(faelleAnzahl);
faelleBadge.hidden = faelleAnzahl === 0;
}
const anzahl = await holeWartendeAnzahl();
const badge = document.getElementById("staging-badge");
if (badge) {
@@ -222,6 +222,7 @@
"nav.group.system": "System",
"nav.instances": "Instanzen",
"nav.staging": "Freigaben",
"nav.cases": "Vorgänge",
"nav.logs": "Protokoll",
"nav.backup": "Sicherung",
"nav.settings": "Einstellungen",
@@ -851,5 +852,65 @@
"desktop.dialog.addService": "Dienst hinzufügen",
"desktop.dialog.restoreBackup": "Sicherung wiederherstellen",
"desktop.dialog.license": "Lizenz-Aktivierung",
"desktop.dialog.instancePicker": "Instanz auswählen"
"desktop.dialog.instancePicker": "Instanz auswählen",
"cases.title": "Vorgänge",
"cases.refresh": "Aktualisieren",
"cases.intro": "Eskalationen und Konflikte, die die Agenten nicht unter sich lösen konnten, und die Beschlüsse, die sie geschlossen haben. Was bis zur Frist niemand löst, landet hier.",
"cases.tabOpen": "Offen",
"cases.tabAll": "Alle",
"cases.tabDecisions": "Beschlüsse",
"cases.none": "Keine offenen Vorgänge",
"cases.noneHint": "Nichts wartet auf eine Entscheidung.",
"cases.noDecisions": "Noch keine Beschlüsse",
"cases.noDecisionsHint": "Jeder entschiedene Vorgang hinterlässt hier einen Beschluss. Er gilt für den Bereich, für den er gefasst wurde, und wird gezeigt, bevor jemand dieselbe Frage erneut stellt.",
"cases.loadFailed": "Vorgänge konnten nicht geladen werden",
"cases.kindEscalation": "Eskalation",
"cases.kindConflict": "Konflikt",
"cases.status.open": "offen",
"cases.status.decided": "entschieden",
"cases.status.handled": "erledigt",
"cases.overdue": "Frist überschritten",
"cases.called": "Mensch gerufen",
"cases.id": "ID",
"cases.openedBy": "Eröffnet von",
"cases.holder": "Liegt bei",
"cases.human": "dir (Mensch)",
"cases.humanAuthor": "Mensch",
"cases.deadline": "Frist",
"cases.level": "Weitergereicht",
"cases.parties": "Seiten",
"cases.informed": "Vermerkt, nicht befragt",
"cases.waitingFor": "Wartet auf",
"cases.origin": "Herkunft",
"cases.trail": "Was jede Ebene gesagt hat",
"cases.entry.opened": "eröffnet",
"cases.entry.joined": "angeschlossen",
"cases.entry.forwarded": "weitergereicht",
"cases.entry.position": "Standpunkt",
"cases.entry.statement": "Stellungnahme",
"cases.entry.statement-requested": "Stellungnahme angefordert",
"cases.entry.called": "Mensch gerufen",
"cases.entry.decided": "entschieden",
"cases.entry.handled": "erledigt",
"cases.entry.note": "Vermerk",
"cases.decide": "Entscheiden",
"cases.decideTitle": "Vorgang {id} entscheiden",
"cases.decideHint": "Deine Entscheidung gilt für die ganze Instanz. Jeder Agent, der dieselbe Frage stellt, bekommt sie vorher angezeigt. Alle, die den Vorgang eröffnet haben oder daran beteiligt sind, erfahren davon.",
"cases.answer": "Entscheidung",
"cases.answerPlaceholder": "Was ab jetzt gilt",
"cases.reasoning": "Begründung",
"cases.reasoningPlaceholder": "Warum. Ohne Begründung lässt sich eine Entscheidung unten nicht anwenden",
"cases.decided": "Beschluss gespeichert.",
"cases.requestStatement": "Stellungnahme anfordern",
"cases.statementTitle": "Stellungnahme zu Vorgang {id} anfordern",
"cases.statementFrom": "Von",
"cases.question": "Frage",
"cases.questionPlaceholder": "Was musst du wissen?",
"cases.statementHint": "Der Agent bekommt eine Aufgabe mit genau dieser Frage und einer eigenen Frist. Der Vorgang bleibt bei dir.",
"cases.statementRequested": "Stellungnahme angefordert.",
"cases.scope": "Gilt für",
"cases.scopeAll": "die ganze Instanz",
"cases.decidedBy": "Entschieden von",
"cases.failed": "Fehlgeschlagen: {error}"
}
@@ -222,6 +222,7 @@
"nav.group.system": "System",
"nav.instances": "Instances",
"nav.staging": "Approvals",
"nav.cases": "Cases",
"nav.logs": "Logs",
"nav.backup": "Backup",
"nav.settings": "Settings",
@@ -851,5 +852,65 @@
"desktop.dialog.addService": "Add Service",
"desktop.dialog.restoreBackup": "Restore Backup",
"desktop.dialog.license": "License Activation",
"desktop.dialog.instancePicker": "Choose Instance"
"desktop.dialog.instancePicker": "Choose Instance",
"cases.title": "Cases",
"cases.refresh": "Refresh",
"cases.intro": "Escalations and conflicts the agents could not solve among themselves, and the decisions that closed them. A case nobody solves by its deadline comes here.",
"cases.tabOpen": "Open",
"cases.tabAll": "All",
"cases.tabDecisions": "Decisions",
"cases.none": "No open cases",
"cases.noneHint": "Nothing waits for a decision.",
"cases.noDecisions": "No decisions yet",
"cases.noDecisionsHint": "Every decided case leaves a decision here. It binds the area it was made for and is shown before anyone asks the same again.",
"cases.loadFailed": "Cases could not be loaded",
"cases.kindEscalation": "Escalation",
"cases.kindConflict": "Conflict",
"cases.status.open": "open",
"cases.status.decided": "decided",
"cases.status.handled": "handled",
"cases.overdue": "past deadline",
"cases.called": "human called",
"cases.id": "ID",
"cases.openedBy": "Opened by",
"cases.holder": "With",
"cases.human": "you (human)",
"cases.humanAuthor": "human",
"cases.deadline": "Deadline",
"cases.level": "Passed on",
"cases.parties": "Sides",
"cases.informed": "Told, not asked",
"cases.waitingFor": "Waiting for",
"cases.origin": "Arose from",
"cases.trail": "What each level said",
"cases.entry.opened": "opened",
"cases.entry.joined": "joined",
"cases.entry.forwarded": "passed on",
"cases.entry.position": "position",
"cases.entry.statement": "statement",
"cases.entry.statement-requested": "statement requested",
"cases.entry.called": "human called",
"cases.entry.decided": "decided",
"cases.entry.handled": "handled",
"cases.entry.note": "note",
"cases.decide": "Decide",
"cases.decideTitle": "Decide case {id}",
"cases.decideHint": "Your decision binds the whole instance and is shown to every agent that raises the same question. Everyone who raised it or is a side of it hears it.",
"cases.answer": "Decision",
"cases.answerPlaceholder": "What holds from now on",
"cases.reasoning": "Reasoning",
"cases.reasoningPlaceholder": "Why — without reasons a decision cannot be applied below",
"cases.decided": "Decision recorded.",
"cases.requestStatement": "Request statement",
"cases.statementTitle": "Request a statement for case {id}",
"cases.statementFrom": "From",
"cases.question": "Question",
"cases.questionPlaceholder": "What do you need to know?",
"cases.statementHint": "The agent gets a task with exactly this question and its own deadline. The case stays with you.",
"cases.statementRequested": "Statement requested.",
"cases.scope": "Binds",
"cases.scopeAll": "the whole instance",
"cases.decidedBy": "Decided by",
"cases.failed": "Failed: {error}"
}
@@ -124,6 +124,13 @@
<span class="nav-label" data-i18n="nav.staging">Approvals</span>
<span id="staging-badge" class="nav-badge" hidden>0</span>
</a>
<a href="#/cases" class="nav-link" title="Cases" data-i18n-title="nav.cases">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M12 3v6 M8 7l4-4 4 4 M4 13h16v8H4z M9 17h6"/>
</svg>
<span class="nav-label" data-i18n="nav.cases">Cases</span>
<span id="faelle-badge" class="nav-badge" hidden>0</span>
</a>
<a href="#/protokoll" class="nav-link" title="Logs" data-i18n-title="nav.logs">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20 M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z M8 7h8 M8 11h6"/>
@@ -0,0 +1,230 @@
// ClawdDotNet — cases: escalations, conflicts and the decisions that close them (#689)
//
// A case nobody solves in time comes to a human. Here the human reads what each level said —
// appended, never replaced (#697) — decides with reasons, or asks one side for a statement.
// The decisions tab is the organisation's memory: what was decided, why, and how far it binds.
//
// Everything in a trail was written by agents and may carry foreign text. It is only ever
// inserted via textContent.
import { api } from "../modules/api.js";
import { t } from "../modules/i18n.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstellePlakette,
erstelleKarte,
erstelleModal,
formatiereDatumZeit,
zeigeMeldung
} from "../modules/dom.js";
import { laufLink } from "./run-detail.js";
let reiter = "open";
/** Open cases a human holds — for the badge in the sidebar. */
export async function holeMenschenfaelle() {
try {
const liste = await api("/cases");
return Array.isArray(liste) ? liste.filter((c) => !c.holder).length : 0;
} catch {
return 0;
}
}
export async function renderCases() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
const kopf = erstelleElement("div", { klasse: "ansicht-kopf" },
erstelleElement("h1", { text: t("cases.title") }),
erstelleElement("button", { klasse: "schaltflaeche flach", onclick: laden },
erstelleIcon(PFADE.aktualisieren, 16),
erstelleElement("span", { text: ` ${t("cases.refresh")}` })));
const leiste = erstelleElement("div", { klasse: "reiter-leiste" });
for (const [wert, schluessel] of [["open", "cases.tabOpen"], ["all", "cases.tabAll"], ["decisions", "cases.tabDecisions"]]) {
leiste.append(erstelleElement("button", {
klasse: `reiter-schaltflaeche${wert === reiter ? " aktiv" : ""}`,
text: t(schluessel),
dataset: { wert },
onclick: () => {
reiter = wert;
for (const b of leiste.children) b.classList.toggle("aktiv", b.dataset.wert === wert);
laden();
}
}));
}
const hinweis = erstelleElement("p", { klasse: "gedaempft", text: t("cases.intro") });
const bereich = erstelleElement("div", { id: "faelle-bereich", klasse: "faelle-liste" });
container.replaceChildren(erstelleElement("div", { klasse: "faelle-ansicht" }, kopf, hinweis, leiste, bereich));
await laden();
}
async function laden() {
const bereich = document.getElementById("faelle-bereich");
if (!bereich) return;
bereich.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") }));
try {
if (reiter === "decisions") {
const liste = await api("/decisions");
bereich.replaceChildren(...(liste.length === 0
? [leer(t("cases.noDecisions"), t("cases.noDecisionsHint"))]
: liste.map(beschlussKarte)));
return;
}
const liste = await api(reiter === "all" ? "/cases?all=true" : "/cases");
// What waits for a human first, then the most urgent deadline.
liste.sort((a, b) => (a.holder ? 1 : 0) - (b.holder ? 1 : 0) || new Date(a.deadlineUtc) - new Date(b.deadlineUtc));
bereich.replaceChildren(...(liste.length === 0
? [leer(t("cases.none"), t("cases.noneHint"))]
: liste.map(fallKarte)));
} catch (err) {
bereich.replaceChildren(leer(t("cases.loadFailed"), err.message));
}
}
function leer(titel, text) {
return erstelleKarte(titel, [erstelleElement("p", { klasse: "gedaempft", text })]);
}
function fallKarte(fall) {
const offen = fall.status === "open";
const beimMenschen = offen && !fall.holder;
const ueberfaellig = offen && new Date(fall.deadlineUtc) < new Date();
const plaketten = erstelleElement("div", { klasse: "gruppe-horizontal fall-plaketten" },
erstellePlakette(t(fall.kind === "conflict" ? "cases.kindConflict" : "cases.kindEscalation"), fall.kind === "conflict" ? "warnung" : "neutral"),
erstellePlakette(t(`cases.status.${fall.status}`), offen ? (beimMenschen ? "gefahr" : "neutral") : "erfolg"));
if (ueberfaellig && !beimMenschen) plaketten.append(erstellePlakette(t("cases.overdue"), "gefahr"));
if (fall.humanCalledAt) plaketten.append(erstellePlakette(t("cases.called"), "warnung"));
const aktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
if (beimMenschen) {
aktionen.append(
erstelleElement("button", { klasse: "schaltflaeche primaer klein", onclick: () => entscheiden(fall) },
erstelleIcon(PFADE.haken, 14), erstelleElement("span", { text: ` ${t("cases.decide")}` })),
erstelleElement("button", { klasse: "schaltflaeche flach klein", onclick: () => stellungnahme(fall) },
erstelleElement("span", { text: t("cases.requestStatement") })));
}
const daten = erstelleElement("dl", { klasse: "datenliste" });
const zeile = (bez, wert) => daten.append(
erstelleElement("dt", { text: t(bez) }),
wert instanceof Node ? erstelleElement("dd", {}, wert) : erstelleElement("dd", { text: wert ?? "—" }));
zeile("cases.id", fall.id);
zeile("cases.openedBy", `${fall.openedBy} · ${formatiereDatumZeit(fall.openedAt)}`);
zeile("cases.holder", fall.holder || t("cases.human"));
zeile("cases.deadline", formatiereDatumZeit(fall.deadlineUtc));
if (fall.level > 0) zeile("cases.level", String(fall.level));
if (fall.parties.length > 0) zeile("cases.parties", fall.parties.join(" · "));
if (fall.informed.length > 0) zeile("cases.informed", fall.informed.join(", "));
if (fall.waitingFor.length > 0) zeile("cases.waitingFor", fall.waitingFor.join(", "));
if (fall.originTaskId || fall.originRunId) {
const herkunft = erstelleElement("span", { text: fall.originTaskId ? `${fall.originTaskId} ` : "" });
if (fall.originRunId) herkunft.append(laufLink(fall.originRunId, t("tasks.originRun")));
zeile("cases.origin", herkunft);
}
const verlauf = erstelleElement("ol", { klasse: "fall-verlauf" });
for (const eintrag of fall.trail) {
const kopfzeile = erstelleElement("div", { klasse: "fall-eintrag-kopf gedaempft text-klein" },
`${formatiereDatumZeit(eintrag.at)} · ${eintrag.author === "human" ? t("cases.humanAuthor") : eintrag.author} · ${t(`cases.entry.${eintrag.kind}`)}`);
if (eintrag.runId) kopfzeile.append(" · ", laufLink(eintrag.runId, t("tasks.originRun")));
verlauf.append(erstelleElement("li", { klasse: `fall-eintrag fall-eintrag-${eintrag.kind}` },
kopfzeile,
erstelleElement("div", { klasse: "fall-eintrag-text", text: eintrag.text })));
}
return erstelleKarte(fall.subject, [
plaketten,
daten,
erstelleElement("h3", { klasse: "fall-verlauf-titel", text: t("cases.trail") }),
verlauf
], { aktionen, klasse: beimMenschen ? "fall-karte fall-beim-menschen" : "fall-karte" });
}
function beschlussKarte(beschluss) {
const daten = erstelleElement("dl", { klasse: "datenliste" });
const zeile = (bez, wert) => daten.append(erstelleElement("dt", { text: t(bez) }), erstelleElement("dd", { text: wert ?? "—" }));
zeile("cases.answer", beschluss.answer);
zeile("cases.reasoning", beschluss.reasoning);
zeile("cases.scope", beschluss.scopePath || t("cases.scopeAll"));
zeile("cases.decidedBy", `${beschluss.decidedBy === "human" ? t("cases.humanAuthor") : beschluss.decidedBy} · ${formatiereDatumZeit(beschluss.decidedAt)}`);
zeile("cases.id", `${beschluss.id} · ${beschluss.caseId}`);
return erstelleKarte(beschluss.subject, [daten], { klasse: "fall-karte" });
}
// ─── the human's two answers ───
function textfeld(platzhalter) {
return erstelleElement("textarea", { rows: 4, required: true, placeholder: platzhalter });
}
function entscheiden(fall) {
const antwort = textfeld(t("cases.answerPlaceholder"));
const begruendung = textfeld(t("cases.reasoningPlaceholder"));
const formular = erstelleElement("form", { klasse: "formular-raster" },
erstelleElement("label", { text: t("cases.answer") }), antwort,
erstelleElement("label", { text: t("cases.reasoning") }), begruendung,
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("cases.decideHint") }));
absenden(t("cases.decideTitle", { id: fall.id }), formular, t("cases.decide"), async () => {
await api(`/cases/${encodeURIComponent(fall.id)}/decide`, {
method: "POST",
body: { answer: antwort.value.trim(), reasoning: begruendung.value.trim() }
});
zeigeMeldung(t("cases.decided"), "info");
});
}
async function stellungnahme(fall) {
const auswahl = erstelleElement("select", { required: true });
let agenten = [];
try { agenten = await api("/agents"); } catch { /* the list stays empty; the server refuses an unknown agent */ }
// The sides and the levels in between first — they are who a statement is usually asked of.
const nahe = new Set([...fall.parties, ...fall.informed, fall.openedBy]);
agenten.sort((a, b) => (nahe.has(b.agentId) ? 1 : 0) - (nahe.has(a.agentId) ? 1 : 0) || a.agentId.localeCompare(b.agentId));
for (const a of agenten) auswahl.append(erstelleElement("option", { value: a.agentId, text: a.displayName && a.displayName !== a.agentId ? `${a.agentId} (${a.displayName})` : a.agentId }));
const frage = textfeld(t("cases.questionPlaceholder"));
const formular = erstelleElement("form", { klasse: "formular-raster" },
erstelleElement("label", { text: t("cases.statementFrom") }), auswahl,
erstelleElement("label", { text: t("cases.question") }), frage,
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("cases.statementHint") }));
absenden(t("cases.statementTitle", { id: fall.id }), formular, t("cases.requestStatement"), async () => {
await api(`/cases/${encodeURIComponent(fall.id)}/statement`, {
method: "POST",
body: { agentId: auswahl.value, question: frage.value.trim() }
});
zeigeMeldung(t("cases.statementRequested"), "info");
});
}
function absenden(titel, formular, knopfText, aktion) {
const abbrechen = erstelleElement("button", { type: "button", klasse: "schaltflaeche flach", text: t("common.cancel"), onclick: () => modal.schliessen() });
const senden = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: knopfText });
const modal = erstelleModal(titel, formular, [abbrechen, senden]);
formular.addEventListener("submit", async (e) => {
e.preventDefault();
senden.disabled = true;
try {
await aktion();
modal.schliessen();
await laden();
} catch (err) {
zeigeMeldung(t("cases.failed", { error: err.message }), "gefahr");
} finally {
senden.disabled = false;
}
});
modal.oeffnen();
}
+43
View File
@@ -0,0 +1,43 @@
namespace ClawdDotNet.Web.Contracts;
/// <summary>#689: A case — escalation or conflict — with everything each level said.</summary>
public sealed record CaseDto(
string Id,
string Kind,
string Status,
string Subject,
string OpenedBy,
DateTime OpenedAt,
DateTime DeadlineUtc,
DateTime? HumanCalledAt,
// null: a human holds it.
string? Holder,
string HolderTaskId,
int Level,
string OriginTaskId,
string OriginRunId,
IReadOnlyList<string> Parties,
IReadOnlyList<string> Informed,
IReadOnlyList<string> WaitingFor,
IReadOnlyList<CaseEntryDto> Trail,
string? DecisionId);
public sealed record CaseEntryDto(DateTime At, string Author, string Kind, string Text, string? RunId);
public sealed record DecisionDto(
string Id,
string Subject,
string Question,
string Answer,
string Reasoning,
string DecidedBy,
DateTime DecidedAt,
string? ScopeUnitId,
// "Purchasing / Team A"; null: binds the whole instance.
string? ScopePath,
string CaseId,
string? RunId);
public sealed record CaseDecisionInputDto(string? Answer, string? Reasoning);
public sealed record CaseStatementInputDto(string? AgentId, string? Question);
@@ -32,6 +32,7 @@ internal static class ApiEndpoints
api.MapAnalytics();
api.MapLogs();
api.MapOrganisation();
api.MapCases();
api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto());
app.MapEvents();
@@ -0,0 +1,71 @@
using ClawdDotNet.App;
using ClawdDotNet.Core.Cases;
using ClawdDotNet.Web.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace ClawdDotNet.Web.Endpoints;
/// <summary>
/// #689: Cases and decisions for the human. Reading is the repository's, deciding the desk's —
/// the same rules as for an agent, with the human as the one holder above everyone.
/// </summary>
internal static class CaseEndpoints
{
public static void MapCases(this RouteGroupBuilder api)
{
var cases = api.MapGroup("/cases");
cases.MapGet("/", async (AppHost host, bool? all, CancellationToken ct) =>
{
if (host.Cases is not { } repo)
return Results.Ok(Array.Empty<CaseDto>());
var list = all == true ? await repo.ListRecentAsync(200, ct) : await repo.ListOpenAsync(ct);
return Results.Ok(list.OrderByDescending(c => c.OpenedAt).Select(ToDto).ToList());
});
cases.MapGet("/{caseId}", async (AppHost host, string caseId, CancellationToken ct) =>
host.Cases is { } repo && await repo.GetAsync(caseId, ct) is { } record
? Results.Ok(ToDto(record))
: Results.NotFound(new OutcomeDto(false, $"No case '{caseId}'.")));
cases.MapPost("/{caseId}/decide", async (AppHost host, string caseId, CaseDecisionInputDto input, CancellationToken ct) =>
{
if (host.CaseDesk is not { } desk)
return Results.BadRequest(new OutcomeDto(false, "Cases are not available in this instance."));
var outcome = await desk.DecideAsync(caseId, CaseRules.Human, input.Answer ?? "", input.Reasoning ?? "", null, ct);
return Respond(outcome);
});
cases.MapPost("/{caseId}/statement", async (AppHost host, string caseId, CaseStatementInputDto input, CancellationToken ct) =>
{
if (host.CaseDesk is not { } desk)
return Results.BadRequest(new OutcomeDto(false, "Cases are not available in this instance."));
var outcome = await desk.RequestStatementAsync(caseId, input.AgentId ?? "", input.Question ?? "", ct);
return Respond(outcome);
});
api.MapGet("/decisions", async (AppHost host, CancellationToken ct) =>
{
if (host.Cases is not { } repo)
return Results.Ok(Array.Empty<DecisionDto>());
var org = host.Organisation.Current();
return Results.Ok((await repo.ListDecisionsAsync(ct)).Select(d => new DecisionDto(
d.Id, d.Subject, d.Question, d.Answer, d.Reasoning, d.DecidedBy, d.DecidedAt,
d.ScopeUnitId, d.ScopeUnitId is null ? null : org.PathOf(d.ScopeUnitId), d.CaseId, d.RunId)).ToList());
});
}
private static IResult Respond(CaseOutcome outcome)
=> outcome.Ok
? Results.Ok(new OutcomeDto(true, outcome.Message))
: Results.BadRequest(new OutcomeDto(false, outcome.Message));
private static CaseDto ToDto(CaseRecord c) => new(
c.Id, c.Kind.ToString().ToLowerInvariant(), c.Status.ToString().ToLowerInvariant(), c.Subject,
c.OpenedBy, c.OpenedAt, c.DeadlineUtc, c.HumanCalledAt, c.HeldByHuman ? null : c.Holder, c.HolderTaskId,
c.Level, c.OriginTaskId, c.OriginRunId, c.Parties, c.Informed, c.PendingStatements.Keys.ToList(),
c.Trail.Select(e => new CaseEntryDto(e.At, e.Author, e.Kind, e.Text, e.RunId)).ToList(),
c.DecisionId);
}