using System.Collections.Concurrent; using System.Text.Json; using System.Text.Json.Nodes; using CareFix.Api.Fixes; using CareFix.Api.Hospitals; using CareFix.Api.Infrastructure; using CareFix.Api.Knowledge; using CareFix.Api.Options; using CareFix.Api.Security; using CareFix.Core; using CareFix.Api.Tickets; using Dapper; using Microsoft.Extensions.Options; namespace CareFix.Api.Ai; /// Runs the tool-calling loop for one ticket. The AI can read and propose; it can never write. public sealed class AiOrchestrator( ControlDb db, ClaudeClient claude, KnowledgeBase kb, SqlGuard guard, PiiMasker masker, IHospitalExecutor exec, FixService fixes, TicketService tickets, AiUsageService usage, IOptions options, ILogger log) { private static readonly ConcurrentDictionary Gates = new(); private const int MaxToolOutputChars = 30_000; private sealed class Transcript { public JsonArray Messages { get; set; } = new(); public string? PendingAskId { get; set; } public JsonArray PendingResults { get; set; } = new(); } public async Task RunAsync(AiJob job, CancellationToken ct) { var gate = Gates.GetOrAdd(job.TicketId, _ => new SemaphoreSlim(1, 1)); await gate.WaitAsync(ct); try { await RunCoreAsync(job, ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception ex) { log.LogError(ex, "AI run failed for ticket {TicketId}", job.TicketId); var msg = ex is AppException app ? app.Message : "an unexpected error occurred (see server log)"; await tickets.AddMessageAsync(job.TicketId, "System", $"The AI could not finish this step: {msg} Send a message to retry.", null, CancellationToken.None); } finally { await tickets.SetAiBusyAsync(job.TicketId, false, CancellationToken.None); gate.Release(); } } private async Task RunCoreAsync(AiJob job, CancellationToken ct) { await usage.EnsureMonthlyBudgetAsync(ct); var t = await tickets.GetRowAsync(job.TicketId, ct); if (t.State is "New" or "NeedsInfo" or "RolledBack") await tickets.SetStateAsync(t.TicketId, "Diagnosing", null, ct); var tr = await LoadAsync(t.TicketId, ct); var text = masker.MaskText(job.Text); if (tr.PendingAskId is not null) { var content = new JsonArray(); foreach (var r in tr.PendingResults) content.Add(r!.DeepClone()); content.Add(ToolResult(tr.PendingAskId, text, false)); AppendUser(tr.Messages, content); tr.PendingAskId = null; tr.PendingResults = new JsonArray(); } else AppendUser(tr.Messages, new JsonArray(new JsonObject { ["type"] = "text", ["text"] = text })); var context = await kb.ContextForTicketAsync(t.HospitalId, t.IssueText, ct); var system = new JsonArray( new JsonObject { ["type"] = "text", ["text"] = AiTools.SystemPrompt, ["cache_control"] = new JsonObject { ["type"] = "ephemeral" } }, new JsonObject { ["type"] = "text", ["text"] = $"Ticket {t.TicketNo}: {t.Title}\nOriginal issue: {t.IssueText}\n\n{context}" }); var maxCalls = Math.Max(3, options.Value.Claude.MaxToolCallsPerRun); var calls = 0; while (true) { var resp = await claude.CreateMessageAsync(system, AiTools.Definitions, tr.Messages, ct, usageContext: new UsageContext(t.TicketId, t.State == "Executed" ? "Verification" : "Diagnosis")); var content = resp["content"] as JsonArray ?? new JsonArray(); tr.Messages.Add(new JsonObject { ["role"] = "assistant", ["content"] = content.DeepClone() }); foreach (var b in content) if (b?["type"]?.ToString() == "text" && b["text"]?.ToString() is { Length: > 0 } said && !string.IsNullOrWhiteSpace(said)) await tickets.AddMessageAsync(t.TicketId, "AI", said.Trim(), null, ct); var toolUses = content.Where(b => b?["type"]?.ToString() == "tool_use").Select(b => b!.AsObject()).ToList(); if (toolUses.Count == 0) { await SaveAsync(t.TicketId, tr, ct); return; } var results = new JsonArray(); string? askId = null, askQuestion = null; foreach (var tu in toolUses) { var id = tu["id"]!.ToString(); var name = tu["name"]!.ToString(); var input = tu["input"] as JsonObject ?? new JsonObject(); calls++; if (name == "ask_engineer") { askId = id; askQuestion = input["question"]?.ToString(); continue; } string output; var isError = false; try { output = await ExecuteToolAsync(t, name, input, ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (AppException ex) { output = ex.Message; isError = true; } catch (Exception ex) when (ex is InvalidOperationException or FormatException or JsonException) { output = "Invalid tool input: " + ex.Message; isError = true; } results.Add(ToolResult(id, Trim(output), isError)); } if (askId is not null) { tr.PendingAskId = askId; tr.PendingResults = results; await SaveAsync(t.TicketId, tr, ct); await tickets.AddMessageAsync(t.TicketId, "AI", askQuestion ?? "Please give more details.", null, ct); await tickets.SetStateAsync(t.TicketId, "NeedsInfo", null, ct); return; } tr.Messages.Add(new JsonObject { ["role"] = "user", ["content"] = results }); await SaveAsync(t.TicketId, tr, ct); if (await usage.TicketOverCapAsync(t.TicketId, ct)) { await tickets.AddMessageAsync(t.TicketId, "System", "This ticket has reached its AI spending cap. A Lead can raise the cap, or take it forward manually.", null, ct); return; } if (calls >= maxCalls) { await tickets.AddMessageAsync(t.TicketId, "System", $"The AI used its {maxCalls} steps for this run. Reply (for example \"continue\") to let it carry on.", null, ct); return; } } } private async Task ExecuteToolAsync(TicketRow t, string name, JsonObject input, CancellationToken ct) { switch (name) { case "search_schema": return JsonSerializer.Serialize(await kb.SearchSchemaAsync(t.HospitalId, input["query"]?.ToString() ?? "", ct)); case "describe_table": return JsonSerializer.Serialize(await kb.DescribeTableAsync(t.HospitalId, input["table"]?.ToString() ?? "", ct)); case "get_playbook": var playbookId = JsonArg.Int(input["playbook_id"]) ?? throw AppException.BadRequest("playbook_id must be a number."); var pb = await kb.GetPlaybookAsync(playbookId, ct) ?? throw AppException.BadRequest("Playbook not found or retired."); return JsonSerializer.Serialize(pb); case "run_select": return await RunSelectAsync(t, input["sql"]?.ToString() ?? "", input["purpose"]?.ToString(), ct); case "propose_fix": return await fixes.ProposeAsync(t, input, ct); case "verify_fix": return await fixes.RecordVerificationAsync(t.TicketId, input, ct); default: throw AppException.BadRequest($"Unknown tool {name}."); } } private async Task RunSelectAsync(TicketRow t, string sql, string? purpose, CancellationToken ct) { var known = await kb.KnownTablesAsync(t.HospitalId, ct); if (known.Count == 0) throw AppException.BadRequest("The schema snapshot for this hospital is missing. Tell the engineer an admin must click 'Capture schema' for this hospital."); var check = guard.ValidateSelect(sql, known); await using var c = await db.OpenAsync(ct); if (!check.Ok) { await c.ExecuteAsync("INSERT CF_QUERY_LOG (TicketId, HospitalId, SqlText, Purpose, Blocked, BlockReason) VALUES (@TicketId, @HospitalId, @sql, @purpose, 1, @r)", new { t.TicketId, t.HospitalId, sql, purpose, r = check.Reason }); throw AppException.BadRequest("Query blocked: " + check.Reason); } var result = await exec.RunSelectAsync(t.HospitalId, sql, ct); var pii = await kb.PiiColumnsAsync(ct); var rows = result.Rows.Select(r => r.Select((v, i) => masker.MaskValue(result.Columns[i], v, pii)).ToArray()).ToList(); var payload = new { columns = result.Columns, rows, rowCount = rows.Count, truncated = result.Truncated }; var json = JsonSerializer.Serialize(payload); var queryId = await c.ExecuteScalarAsync(""" INSERT CF_QUERY_LOG (TicketId, HospitalId, SqlText, Purpose, RowsReturned, Truncated, DurationMs, ResultJson) OUTPUT INSERTED.QueryId VALUES (@TicketId, @HospitalId, @sql, @purpose, @n, @Truncated, @DurationMs, @json) """, new { t.TicketId, t.HospitalId, sql, purpose, n = rows.Count, result.Truncated, result.DurationMs, json }); if (rows.Count == 0) return $"Query {queryId}: 0 rows. Check identifiers, table choice or date range."; return $"Query {queryId}: {rows.Count} row(s){(result.Truncated ? " (stopped at the 200-row limit; narrow the filter)" : "")}.\n{json}"; } // ------------------------------------------------------------------ transcript helpers private static JsonObject ToolResult(string id, string content, bool isError) => new() { ["type"] = "tool_result", ["tool_use_id"] = id, ["content"] = content, ["is_error"] = isError, }; /// Adds user content, merging with a trailing user turn so roles always alternate. private static void AppendUser(JsonArray messages, JsonArray blocks) { if (messages.Count > 0 && messages[^1]?["role"]?.ToString() == "user" && messages[^1]!["content"] is JsonArray existing) { foreach (var b in blocks) existing.Add(b!.DeepClone()); return; } messages.Add(new JsonObject { ["role"] = "user", ["content"] = blocks.DeepClone() }); } private static string Trim(string s) => s.Length <= MaxToolOutputChars ? s : s[..MaxToolOutputChars] + "\n[output cut to save space; select fewer columns or rows]"; private async Task LoadAsync(int ticketId, CancellationToken ct) { await using var c = await db.OpenAsync(ct); var json = await c.ExecuteScalarAsync("SELECT StateJson FROM CF_AI_TRANSCRIPT WHERE TicketId=@ticketId", new { ticketId }); if (json is null) return new Transcript(); var o = JsonNode.Parse(json)!.AsObject(); return new Transcript { Messages = (JsonArray)(o["messages"]?.DeepClone() ?? new JsonArray()), PendingAskId = o["pendingAskId"]?.ToString(), PendingResults = (JsonArray)(o["pendingResults"]?.DeepClone() ?? new JsonArray()), }; } private async Task SaveAsync(int ticketId, Transcript tr, CancellationToken ct) { var json = new JsonObject { ["messages"] = tr.Messages.DeepClone(), ["pendingAskId"] = tr.PendingAskId, ["pendingResults"] = tr.PendingResults.DeepClone(), }.ToJsonString(); await using var c = await db.OpenAsync(ct); await c.ExecuteAsync(""" MERGE CF_AI_TRANSCRIPT AS t USING (SELECT @ticketId AS TicketId) s ON t.TicketId = s.TicketId WHEN MATCHED THEN UPDATE SET StateJson = @json, UpdatedAt = SYSUTCDATETIME() WHEN NOT MATCHED THEN INSERT (TicketId, StateJson) VALUES (@ticketId, @json); """, new { ticketId, json }); } }