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 });
}
}