Files
ClawdDotNet/src/ClawdDotNet.Core/Cases/CaseRules.cs
T
RichardandClaude Opus 5.5 36fe1ad2aa Eskalation, Konflikt und Beschluss als eigene Vorgaenge (#689, Stufe 1+2)
- Cases/Decisions in der Instanz-DB; CaseDesk fuehrt die Regeln, der Brett-Task traegt
  den Vorgang zum jeweiligen Halter
- Eskalation nur nach oben, eine Ebene je Schritt; die Leitung entscheidet (mit Begruendung,
  bindet ihren Bereich), erledigt selbst oder reicht mit Einschaetzung weiter
- Erst nachsehen: bindende Beschluesse werden vor dem Eskalieren gezeigt; dieselbe Frage
  aus derselben Einheit schliesst sich dem offenen Vorgang an
- Tagesdeckel je Einheit; nach der Frist wird ein Mensch gerufen (Anschluss fuer #644)
- Konflikt: zuerst die Gegenseite, dann Zustellung an die niedrigste gemeinsame Leitung;
  Ebenen dazwischen vermerkt, nicht befragt
- Fremdinhalt-Stufe (#715): Eskalation ohne eigenen Text, kein Konflikt mit Position
- Jede Ebene haengt an (#697), beim Halter gerahmt als Daten
- Escalation ist ein implizites Werkzeug jedes Agenten; Briefing nennt den Weg
- Scanner behaelt einen im Lauf gesetzten Status blockiert/abgebrochen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 09:46:20 +02:00

145 lines
6.2 KiB
C#

using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Tasks;
namespace ClawdDotNet.Core.Cases;
/// <summary>Where a conflict goes: who decides, who is told, or why it is no conflict at all.</summary>
public sealed record ConflictRoute(string Decider, IReadOnlyList<string> Informed, string? Refusal)
{
public bool DecidedByHuman => string.IsNullOrWhiteSpace(Decider);
}
/// <summary>
/// #689: The rules of escalation, conflict and decision — all of them read off the tree, none
/// of them configured (Organisation-Konzept, section 5). Pure, so each rule can be tested on an
/// organisation chart alone.
/// </summary>
public static class CaseRules
{
/// <summary>The human, as a holder or a decider.</summary>
public const string Human = "";
/// <summary>
/// Who an escalation goes to next: the agent's own lead — never sideways, never past a level.
/// Without a lead above, a human.
/// </summary>
public static string NextHolder(OrgChart? org, string agentId)
=> org?.EffectiveLeaderOf(agentId)?.AgentId ?? Human;
/// <summary>
/// A conflict is decided at the lowest place above both sides. It is delivered there
/// directly — the levels in between cannot decide it and are told, not asked.
/// </summary>
public static ConflictRoute RouteConflict(OrgChart? org, string a, string b)
{
if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase))
return new ConflictRoute(Human, [], "A conflict needs two sides.");
if (org is not { HasUnits: true })
return new ConflictRoute(Human, [], null);
if (ReportingLine.IsAbove(org, b, a))
return new ConflictRoute(Human, [], $"'{a}' stands above '{b}' and can decide this itself — that is no conflict.");
if (ReportingLine.IsAbove(org, a, b))
return new ConflictRoute(Human, [], $"'{b}' stands above '{a}' and decides this — escalate to it instead.");
var pathA = PathUp(org, a);
var pathB = PathUp(org, b);
var commonIndex = pathA.FindIndex(u => pathB.Contains(u, StringComparer.OrdinalIgnoreCase));
var decider = Human;
if (commonIndex >= 0)
{
for (var i = commonIndex; i < pathA.Count; i++)
{
if (org.LeaderOf(pathA[i]) is { } lead
&& !string.Equals(lead.AgentId, a, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(lead.AgentId, b, StringComparison.OrdinalIgnoreCase))
{
decider = lead.AgentId;
break;
}
}
}
// The leads between each side and the common place: concerned, told, not asked.
var informed = new List<string>();
void Between(List<string> path, int stopAt)
{
for (var i = 0; i < stopAt && i < path.Count; i++)
if (org.LeaderOf(path[i]) is { } lead
&& !string.Equals(lead.AgentId, a, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(lead.AgentId, b, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(lead.AgentId, decider, StringComparison.OrdinalIgnoreCase)
&& !informed.Contains(lead.AgentId, StringComparer.OrdinalIgnoreCase))
informed.Add(lead.AgentId);
}
var common = commonIndex >= 0 ? pathA[commonIndex] : null;
Between(pathA, common is null ? pathA.Count : commonIndex);
Between(pathB, common is null ? pathB.Count : pathB.FindIndex(u => string.Equals(u, common, StringComparison.OrdinalIgnoreCase)));
return new ConflictRoute(decider, informed, null);
}
/// <summary>The unit of the agent and all units above it, nearest first.</summary>
private static List<string> PathUp(OrgChart org, string agentId)
=> org.UnitOf(agentId) is { } unit
? [unit.Id, .. org.Ancestors(unit.Id).Select(u => u.Id)]
: [];
/// <summary>
/// How far a decision reaches: the unit the deciding lead leads, with everything below it.
/// A human decides for the whole instance.
/// </summary>
public static string? ScopeOf(OrgChart? org, string decider)
{
if (string.IsNullOrWhiteSpace(decider) || org is not { HasUnits: true })
return null;
return org.LedBy(decider)?.Id ?? org.UnitOf(decider)?.Id;
}
/// <summary>Whether a decision binds the agent: it is in the decision's unit or below it.</summary>
public static bool Binds(OrgChart? org, Decision decision, string agentId)
{
if (decision.ScopeUnitId is null)
return true;
if (org is null)
return false;
return PathUp(org, agentId).Contains(decision.ScopeUnitId, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Decisions that answer the question, closest first. Deliberately shallow, like the
/// duplicate check of the board (#674): the subject compared as a title, or at least two
/// telling words shared with subject and question.
/// </summary>
public static IReadOnlyList<Decision> Find(IEnumerable<Decision> decisions, string subject, string? question = null)
{
var wanted = Words(subject + " " + question);
return decisions
.Select(d => (Decision: d, Score: Score(d, subject, wanted)))
.Where(x => x.Score > 0)
.OrderByDescending(x => x.Score)
.ThenByDescending(x => x.Decision.DecidedAt)
.Select(x => x.Decision)
.ToList();
}
private static int Score(Decision decision, string subject, HashSet<string> wanted)
{
var match = TaskSimilarity.Compare(decision.Subject, subject);
if (match == TaskTitleMatch.Duplicate) return 100;
if (match == TaskTitleMatch.Similar) return 50;
var shared = Words(decision.Subject + " " + decision.Question).Count(wanted.Contains);
return shared >= 2 ? shared : 0;
}
/// <summary>The words worth comparing: normalised, four letters or more.</summary>
private static HashSet<string> Words(string text)
=> TaskSimilarity.Normalize(text)
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(w => w.Length >= 4)
.ToHashSet(StringComparer.Ordinal);
}