Entscheidungsinstanz an der Spitze: Mensch, Agent oder Mensch zuerst mit Frist (#690)
- cases.top in instance.json: Human | Agent | HumanFirst, agentId, humanFirstHours - TopRole: der entscheidende Agent steht in keiner Einheit, liest keine Fremdinhalte und hat nur Taskboard, Escalation, Memory, FileRW, AgentComm; geprueft beim Setzen, bei jeder Zustellung und vor jeder Entscheidung - CaseDesk: was bisher an der Spitze beim Menschen landete, geht nach Einstellung an den Agenten; Tagesdeckel und Frist gehen weiter an den Menschen. handle verwehrt, forward geht an den Menschen. Mensch zuerst: Uebergabe nach Ablauf, eigene Frist. Der Mensch kann einen Fall des Agenten selbst entscheiden. - Beschluesse des Agenten: ByTopAgent, zum Lesen vorgelegt; jeder Beschluss laesst sich mit Begruendung zuruecknehmen und bindet dann niemanden mehr - Briefing fuer den Agenten an der Spitze, auch ohne Einheiten - OrgAdministration.SetTop mit Audit; Werkzeugsperre und Verschiebesperre - WebUI Vorgaenge: Reiter Spitze, Lesen/Zuruecknehmen, Zaehler; i18n en/de - Tests: TopRoleTests (15), OrgAdministrationTests (+2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e22edb418a
commit
e8964e3c74
@@ -20,7 +20,11 @@ public sealed record CaseDto(
|
||||
IReadOnlyList<string> Informed,
|
||||
IReadOnlyList<string> WaitingFor,
|
||||
IReadOnlyList<CaseEntryDto> Trail,
|
||||
string? DecisionId);
|
||||
string? DecisionId,
|
||||
// #690: the deciding agent at the top holds it — the human can still decide it.
|
||||
bool HeldByTop = false,
|
||||
// #690, human first: when the deciding agent takes over without an answer.
|
||||
DateTime? TopAgentAt = null);
|
||||
|
||||
public sealed record CaseEntryDto(DateTime At, string Author, string Kind, string Text, string? RunId);
|
||||
|
||||
@@ -36,8 +40,28 @@ public sealed record DecisionDto(
|
||||
// "Purchasing / Team A"; null: binds the whole instance.
|
||||
string? ScopePath,
|
||||
string CaseId,
|
||||
string? RunId);
|
||||
string? RunId,
|
||||
// #690: taken by the deciding agent at the top, and whether the human has read or withdrawn it.
|
||||
bool ByTopAgent = false,
|
||||
DateTime? ReviewedAt = null,
|
||||
DateTime? RevokedAt = null,
|
||||
string? RevokedReason = null);
|
||||
|
||||
public sealed record CaseDecisionInputDto(string? Answer, string? Reasoning);
|
||||
|
||||
public sealed record DecisionRevokeInputDto(string? Reason);
|
||||
|
||||
/// <summary>#690: Who decides at the top, and which agents could.</summary>
|
||||
public sealed record TopDto(
|
||||
string Mode,
|
||||
string? AgentId,
|
||||
double HumanFirstHours,
|
||||
// Why the named agent does not take cases now; null when it does, or no agent is named.
|
||||
string? Problem,
|
||||
IReadOnlyList<TopCandidateDto> Candidates);
|
||||
|
||||
public sealed record TopCandidateDto(string AgentId, string DisplayName, string? Unfit);
|
||||
|
||||
public sealed record TopInputDto(string? Mode, string? AgentId, double? HumanFirstHours, string? Reason);
|
||||
|
||||
public sealed record CaseStatementInputDto(string? AgentId, string? Question);
|
||||
|
||||
@@ -22,12 +22,13 @@ internal static class CaseEndpoints
|
||||
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());
|
||||
var top = host.CaseDesk?.TopAgentId;
|
||||
return Results.Ok(list.OrderByDescending(c => c.OpenedAt).Select(c => ToDto(c, top)).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.Ok(ToDto(record, host.CaseDesk?.TopAgentId))
|
||||
: Results.NotFound(new OutcomeDto(false, $"No case '{caseId}'.")));
|
||||
|
||||
cases.MapPost("/{caseId}/decide", async (AppHost host, string caseId, CaseDecisionInputDto input, CancellationToken ct) =>
|
||||
@@ -53,8 +54,38 @@ internal static class CaseEndpoints
|
||||
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());
|
||||
d.ScopeUnitId, d.ScopeUnitId is null ? null : org.PathOf(d.ScopeUnitId), d.CaseId, d.RunId,
|
||||
d.ByTopAgent, d.ReviewedAt, d.RevokedAt, d.RevokedReason)).ToList());
|
||||
});
|
||||
|
||||
// #690: The human reads what the deciding agent decided — and withdraws what does not hold.
|
||||
api.MapPost("/decisions/{decisionId}/review", async (AppHost host, string decisionId, CancellationToken ct) =>
|
||||
host.CaseDesk is { } desk
|
||||
? Respond(await desk.ReviewDecisionAsync(decisionId, ct))
|
||||
: Results.BadRequest(new OutcomeDto(false, "Cases are not available in this instance.")));
|
||||
|
||||
api.MapPost("/decisions/{decisionId}/revoke", async (AppHost host, string decisionId, DecisionRevokeInputDto input, CancellationToken ct) =>
|
||||
host.CaseDesk is { } desk
|
||||
? Respond(await desk.RevokeDecisionAsync(decisionId, input.Reason ?? "", ct))
|
||||
: Results.BadRequest(new OutcomeDto(false, "Cases are not available in this instance.")));
|
||||
|
||||
// #690: Who decides at the top.
|
||||
cases.MapGet("/top", (AppHost host) =>
|
||||
{
|
||||
var top = host.Instance.Cases.Top;
|
||||
var org = host.Organisation.Current();
|
||||
return Results.Ok(new TopDto(
|
||||
top.Mode.ToString(), top.AgentId, top.HumanFirstHours,
|
||||
TopRole.Resolve(top, org).Problem,
|
||||
org.Agents.OrderBy(a => a.AgentId, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(a => new TopCandidateDto(a.AgentId, a.DisplayName ?? a.AgentId, TopRole.Unfit(a, a.AgentId)))
|
||||
.ToList()));
|
||||
});
|
||||
|
||||
cases.MapPut("/top", (AppHost host, TopInputDto input) =>
|
||||
Enum.TryParse<TopMode>(input.Mode, ignoreCase: true, out var mode)
|
||||
? host.Organisation.SetTop(mode, input.AgentId, input.HumanFirstHours, input.Reason ?? "").ToResponse()
|
||||
: Results.BadRequest(new OutcomeDto(false, $"Unknown mode '{input.Mode}' — human, agent or humanFirst.")));
|
||||
}
|
||||
|
||||
private static IResult Respond(CaseOutcome outcome)
|
||||
@@ -62,10 +93,12 @@ internal static class CaseEndpoints
|
||||
? Results.Ok(new OutcomeDto(true, outcome.Message))
|
||||
: Results.BadRequest(new OutcomeDto(false, outcome.Message));
|
||||
|
||||
private static CaseDto ToDto(CaseRecord c) => new(
|
||||
private static CaseDto ToDto(CaseRecord c, string? topAgentId) => 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);
|
||||
c.DecisionId,
|
||||
!c.HeldByHuman && string.Equals(c.Holder, topAgentId, StringComparison.OrdinalIgnoreCase),
|
||||
c.TopAgentAt);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user