diff --git a/docs/Organisation-Konzept.md b/docs/Organisation-Konzept.md index eb74dc9..1598bbf 100644 --- a/docs/Organisation-Konzept.md +++ b/docs/Organisation-Konzept.md @@ -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 diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index fe28cc3..8048fec 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -61,6 +61,12 @@ public sealed class AppHost : IAsyncDisposable public required ILoggerFactory LoggerFactory { get; init; } public required ToolRegistry Tools { get; init; } + /// #689: Escalations, conflicts and decisions — read by the interfaces, acted on through the desk. + public Core.Cases.ICaseRepository? Cases { get; init; } + + /// #689: The desk a human decides at; null without a task board. + public Core.Cases.ICaseDesk? CaseDesk => Engine?.CaseDesk; + /// /// 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) diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.css b/src/ClawdDotNet.Manager.Web/wwwroot/app.css index 8e36248..96240c3 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/app.css +++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.css @@ -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; diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.js b/src/ClawdDotNet.Manager.Web/wwwroot/app.js index cc0fcc4..ca41b77 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/app.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.js @@ -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) { diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index 4e88872..84ef56f 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -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}" } diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index f6a6c0e..760f11e 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -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}" } diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/index.html b/src/ClawdDotNet.Manager.Web/wwwroot/index.html index 8526081..1ddccd9 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/index.html +++ b/src/ClawdDotNet.Manager.Web/wwwroot/index.html @@ -124,6 +124,13 @@ Approvals + + + + + Cases + + diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/cases.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/cases.js new file mode 100644 index 0000000..18402b9 --- /dev/null +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/cases.js @@ -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(); +} diff --git a/src/ClawdDotNet.Web/Contracts/CaseDtos.cs b/src/ClawdDotNet.Web/Contracts/CaseDtos.cs new file mode 100644 index 0000000..edeb41d --- /dev/null +++ b/src/ClawdDotNet.Web/Contracts/CaseDtos.cs @@ -0,0 +1,43 @@ +namespace ClawdDotNet.Web.Contracts; + +/// #689: A case — escalation or conflict — with everything each level said. +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 Parties, + IReadOnlyList Informed, + IReadOnlyList WaitingFor, + IReadOnlyList 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); diff --git a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs index 15509a8..b74eed2 100644 --- a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs +++ b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs @@ -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(); diff --git a/src/ClawdDotNet.Web/Endpoints/CaseEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/CaseEndpoints.cs new file mode 100644 index 0000000..2519216 --- /dev/null +++ b/src/ClawdDotNet.Web/Endpoints/CaseEndpoints.cs @@ -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; + +/// +/// #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. +/// +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()); + 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()); + 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); +}