using System.Diagnostics; using System.Text; using System.Text.Json; using Journal.Core.Dtos; using Journal.Core.Models; namespace Journal.Core.Services; public sealed class PythonSidecarAiService : IAiService { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; private readonly JournalConfig _config; public PythonSidecarAiService(JournalConfig config) { _config = config; if (string.IsNullOrWhiteSpace(_config.PythonAiSidecarPath)) throw new ArgumentException("Python AI sidecar path is required."); if (!File.Exists(_config.PythonAiSidecarPath)) throw new FileNotFoundException($"Python AI sidecar not found: {_config.PythonAiSidecarPath}"); } public async Task HealthAsync(CancellationToken cancellationToken = default) { var data = await SendAsync("health", payload: new { }, cancellationToken); if (data is not JsonElement payload || payload.ValueKind != JsonValueKind.Object) return new AiHealthDto("python-sidecar", Enabled: true, Healthy: true, Message: "ok"); var provider = payload.TryGetProperty("provider", out var providerNode) ? providerNode.GetString() ?? "python-sidecar" : "python-sidecar"; var message = payload.TryGetProperty("message", out var messageNode) ? messageNode.GetString() ?? "ok" : "ok"; var healthy = !payload.TryGetProperty("healthy", out var healthyNode) || healthyNode.ValueKind is JsonValueKind.True || (healthyNode.ValueKind is JsonValueKind.False ? false : true); return new AiHealthDto(provider, Enabled: true, Healthy: healthy, Message: message); } public async Task SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(content)) throw new ArgumentException("Entry content is required.", nameof(content)); var data = await SendAsync("summarize_entry", new { content, file_stem = fileStem }, cancellationToken); return data?.GetString() ?? ""; } public async Task SummarizeAllAsync(IReadOnlyList entries, CancellationToken cancellationToken = default) { entries ??= []; var data = await SendAsync("summarize_all", new { entries }, cancellationToken); return data?.GetString() ?? ""; } public async Task ChatAsync(string prompt, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(prompt)) throw new ArgumentException("Prompt is required.", nameof(prompt)); var data = await SendAsync("chat", new { prompt }, cancellationToken); return data?.GetString() ?? ""; } public async Task> EmbedAsync(string content, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(content)) throw new ArgumentException("Content is required.", nameof(content)); var data = await SendAsync("embed", new { content }, cancellationToken); if (data is null || data.Value.ValueKind == JsonValueKind.Null) return []; if (data.Value.ValueKind != JsonValueKind.Array) throw new InvalidOperationException("Python AI sidecar embed response must be a numeric array."); var values = new List(); foreach (var item in data.Value.EnumerateArray()) { if (item.ValueKind != JsonValueKind.Number) throw new InvalidOperationException("Python AI sidecar embed response contains a non-numeric value."); values.Add(item.GetDouble()); } return values; } private async Task SendAsync(string action, object payload, CancellationToken cancellationToken) { var request = JsonSerializer.Serialize(new { action, payload }, JsonOptions); using var process = new Process(); process.StartInfo = new ProcessStartInfo { FileName = _config.PythonExecutable, UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, WorkingDirectory = _config.ProjectRoot }; process.StartInfo.ArgumentList.Add(_config.PythonAiSidecarPath); if (!process.Start()) throw new InvalidOperationException("Failed to start Python AI sidecar process."); await process.StandardInput.WriteLineAsync(request); process.StandardInput.Close(); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(_config.AiSidecarTimeoutMs); try { await process.WaitForExitAsync(timeoutCts.Token); } catch (OperationCanceledException) { TryKill(process); throw new TimeoutException($"Python AI sidecar timed out after {_config.AiSidecarTimeoutMs} ms."); } var stdout = await process.StandardOutput.ReadToEndAsync(); var stderr = await process.StandardError.ReadToEndAsync(); var line = LastJsonLine(stdout); if (string.IsNullOrWhiteSpace(line)) throw new InvalidOperationException($"Python AI sidecar returned no JSON response. stderr: {stderr}".Trim()); JsonDocument doc; try { doc = JsonDocument.Parse(line); } catch (JsonException ex) { throw new InvalidOperationException($"Invalid JSON from Python AI sidecar: {line}", ex); } using (doc) { var root = doc.RootElement; if (!root.TryGetProperty("ok", out var okNode) || okNode.ValueKind != JsonValueKind.True && okNode.ValueKind != JsonValueKind.False) throw new InvalidOperationException("Python AI sidecar response missing boolean 'ok' field."); if (!okNode.GetBoolean()) { var error = root.TryGetProperty("error", out var errorNode) ? errorNode.GetString() ?? "Unknown sidecar error." : "Unknown sidecar error."; throw new InvalidOperationException(error); } if (!root.TryGetProperty("data", out var dataNode)) return null; return dataNode.Clone(); } } private static string LastJsonLine(string text) { var lines = text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); for (var i = lines.Length - 1; i >= 0; i--) { var line = lines[i].Trim(); if (line.StartsWith("{", StringComparison.Ordinal) && line.EndsWith("}", StringComparison.Ordinal)) return line; } return ""; } private static void TryKill(Process process) { try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch { // Ignore cleanup errors while handling timeout/failure path. } } }