191 lines
7.2 KiB
C#
191 lines
7.2 KiB
C#
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<AiHealthDto> 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<string> 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<string> SummarizeAllAsync(IReadOnlyList<string> entries, CancellationToken cancellationToken = default)
|
|
{
|
|
entries ??= [];
|
|
var data = await SendAsync("summarize_all", new { entries }, cancellationToken);
|
|
return data?.GetString() ?? "";
|
|
}
|
|
|
|
public async Task<string> 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<IReadOnlyList<double>> 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<double>();
|
|
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<JsonElement?> 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.
|
|
}
|
|
}
|
|
}
|