using ClawdDotNet.Core.Organisation; using ClawdDotNet.Core.Tasks; namespace ClawdDotNet.Core.Cases; /// Where a conflict goes: who decides, who is told, or why it is no conflict at all. public sealed record ConflictRoute(string Decider, IReadOnlyList Informed, string? Refusal) { public bool DecidedByHuman => string.IsNullOrWhiteSpace(Decider); } /// /// #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. /// public static class CaseRules { /// The human, as a holder or a decider. public const string Human = ""; /// /// Who an escalation goes to next: the agent's own lead — never sideways, never past a level. /// Without a lead above, a human. /// public static string NextHolder(OrgChart? org, string agentId) => org?.EffectiveLeaderOf(agentId)?.AgentId ?? Human; /// /// 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. /// 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(); void Between(List 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); } /// The unit of the agent and all units above it, nearest first. private static List PathUp(OrgChart org, string agentId) => org.UnitOf(agentId) is { } unit ? [unit.Id, .. org.Ancestors(unit.Id).Select(u => u.Id)] : []; /// /// How far a decision reaches: the unit the deciding lead leads, with everything below it. /// A human decides for the whole instance. /// 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; } /// Whether a decision binds the agent: it is in the decision's unit or below it. 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); } /// /// 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. /// public static IReadOnlyList Find(IEnumerable 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 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; } /// The words worth comparing: normalised, four letters or more. private static HashSet Words(string text) => TaskSimilarity.Normalize(text) .Split(' ', StringSplitOptions.RemoveEmptyEntries) .Where(w => w.Length >= 4) .ToHashSet(StringComparer.Ordinal); }