refactor: remove Python sidecar from C# projects
- Delete PythonSidecarAiService, PythonSidecarSpeechService, PythonSidecarClient - Remove PythonExecutable, PythonAiSidecarPath, AiSidecarTimeoutMs from JournalConfig - Remove python-sidecar as valid AiProvider (only none/llamasharp remain) - Simplify DI: default IAiService is DisabledAiService, ISpeechBridgeService is disabled - Remove python-sidecar fallback from Journal.AI ServiceCollectionExtensions - Remove 5 Python sidecar smoke tests and BuildAiConfig helper - Remove Python config assertions from TestConfigServiceParityKeysAsync Co-Authored-By: Oz <oz-agent@warp.dev>
@ -1,6 +1,5 @@
|
||||
using Journal.Core.Services.Ai;
|
||||
using Journal.Core.Services.Config;
|
||||
using Journal.Core.Services.Sidecar;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Journal.AI;
|
||||
@ -34,21 +33,6 @@ public static class ServiceCollectionExtensions
|
||||
}
|
||||
}
|
||||
|
||||
if (string.Equals(config.AiProvider, "python-sidecar", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
return new PythonSidecarAiService(config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new DisabledAiService(
|
||||
provider: "python-sidecar",
|
||||
message: $"Python AI sidecar unavailable: {ex.Message}",
|
||||
healthy: false);
|
||||
}
|
||||
}
|
||||
|
||||
return new DisabledAiService(config.AiProvider);
|
||||
});
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 995 B |
BIN
Journal.App/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 935 B |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.7 KiB |
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
Journal.App/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
BIN
Journal.App/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
BIN
Journal.App/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 13 KiB |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 104 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 552 B |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 815 B |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 477 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
@ -22,7 +22,4 @@ public sealed record JournalConfig(
|
||||
string WhisperModelSize,
|
||||
string NlpBackend,
|
||||
string AiProvider,
|
||||
string PythonExecutable,
|
||||
string PythonAiSidecarPath,
|
||||
int AiSidecarTimeoutMs,
|
||||
string GgufModelPath);
|
||||
|
||||
@ -30,35 +30,10 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<IAiService>(provider =>
|
||||
{
|
||||
var config = provider.GetRequiredService<IJournalConfigService>().Current;
|
||||
if (!string.Equals(config.AiProvider, "python-sidecar", StringComparison.OrdinalIgnoreCase))
|
||||
return new DisabledAiService(config.AiProvider);
|
||||
|
||||
try
|
||||
{
|
||||
return new PythonSidecarAiService(config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new DisabledAiService(
|
||||
provider: "python-sidecar",
|
||||
message: $"Python AI sidecar unavailable: {ex.Message}",
|
||||
healthy: false);
|
||||
}
|
||||
});
|
||||
services.AddSingleton<ISpeechBridgeService>(provider =>
|
||||
{
|
||||
var config = provider.GetRequiredService<IJournalConfigService>().Current;
|
||||
try
|
||||
{
|
||||
return new PythonSidecarSpeechService(config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new DisabledSpeechBridgeService(
|
||||
provider: "python-sidecar",
|
||||
message: $"Python speech sidecar unavailable: {ex.Message}");
|
||||
}
|
||||
return new DisabledAiService(config.AiProvider);
|
||||
});
|
||||
services.AddSingleton<ISpeechBridgeService>(
|
||||
new DisabledSpeechBridgeService("none"));
|
||||
services.AddSingleton<IS2TService, DisabledS2TService>();
|
||||
services.AddSingleton<IEntryFileRepository, SqliteEntryFileRepository>();
|
||||
services.AddSingleton<IEntryFileService, EntryFileService>();
|
||||
|
||||
@ -1,93 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Journal.Core.Dtos;
|
||||
using Journal.Core.Models;
|
||||
using Journal.Core.Services.Sidecar;
|
||||
|
||||
namespace Journal.Core.Services.Ai;
|
||||
|
||||
public sealed class PythonSidecarAiService : IAiService
|
||||
{
|
||||
private readonly PythonSidecarClient _client;
|
||||
|
||||
public PythonSidecarAiService(JournalConfig 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}");
|
||||
_client = new PythonSidecarClient(config);
|
||||
}
|
||||
|
||||
public async Task<AiHealthDto> HealthAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var data = await _client.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 not JsonValueKind.False);
|
||||
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 _client.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 _client.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 _client.SendAsync("chat", new { prompt }, cancellationToken);
|
||||
return data?.GetString() ?? "";
|
||||
}
|
||||
|
||||
public Task<string> ChatWithHistoryAsync(IReadOnlyList<(string Role, string Text)> history,
|
||||
string prompt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Python sidecar does not support multi-turn — fall back to single-turn
|
||||
return ChatAsync(prompt, cancellationToken);
|
||||
}
|
||||
|
||||
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 _client.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;
|
||||
}
|
||||
}
|
||||
@ -22,17 +22,9 @@ public sealed class JournalConfigService : IJournalConfigService
|
||||
nlpBackend = "auto";
|
||||
|
||||
var aiProvider = (Environment.GetEnvironmentVariable("JOURNAL_AI_PROVIDER") ?? "llamasharp").Trim().ToLowerInvariant();
|
||||
if (aiProvider is not ("none" or "python-sidecar" or "llamasharp"))
|
||||
if (aiProvider is not ("none" or "llamasharp"))
|
||||
aiProvider = "none";
|
||||
|
||||
var pythonExecutable = Environment.GetEnvironmentVariable("JOURNAL_PYTHON_EXE");
|
||||
if (string.IsNullOrWhiteSpace(pythonExecutable))
|
||||
pythonExecutable = "python";
|
||||
|
||||
var defaultAiSidecarPath = Path.Combine(projectRoot, "journal", "ai", "sidecar.py");
|
||||
var pythonAiSidecarPath = ResolvePath("JOURNAL_AI_SIDECAR_PATH", defaultAiSidecarPath);
|
||||
var aiSidecarTimeoutMs = ParseInt("JOURNAL_AI_TIMEOUT_MS", 45000);
|
||||
|
||||
return new JournalConfig(
|
||||
ProjectRoot: projectRoot,
|
||||
AppDirectory: appDirectory,
|
||||
@ -55,9 +47,6 @@ public sealed class JournalConfigService : IJournalConfigService
|
||||
WhisperModelSize: Environment.GetEnvironmentVariable("WHISPER_MODEL_SIZE") ?? "base",
|
||||
NlpBackend: nlpBackend,
|
||||
AiProvider: aiProvider,
|
||||
PythonExecutable: pythonExecutable,
|
||||
PythonAiSidecarPath: pythonAiSidecarPath,
|
||||
AiSidecarTimeoutMs: aiSidecarTimeoutMs,
|
||||
GgufModelPath: ResolveGgufModelPath(projectRoot));
|
||||
}
|
||||
|
||||
|
||||
@ -1,114 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Journal.Core.Models;
|
||||
|
||||
namespace Journal.Core.Services.Sidecar;
|
||||
|
||||
public sealed class PythonSidecarClient(JournalConfig config)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly JournalConfig _config = config;
|
||||
|
||||
public 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 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 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 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 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 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('{') && line.EndsWith('}'))
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,81 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Journal.Core.Dtos;
|
||||
using Journal.Core.Models;
|
||||
using Journal.Core.Services.Sidecar;
|
||||
|
||||
namespace Journal.Core.Services.Speech;
|
||||
|
||||
public sealed class PythonSidecarSpeechService : ISpeechBridgeService
|
||||
{
|
||||
private readonly PythonSidecarClient _client;
|
||||
|
||||
public PythonSidecarSpeechService(JournalConfig config)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config.PythonAiSidecarPath))
|
||||
throw new ArgumentException("Python sidecar path is required.");
|
||||
if (!File.Exists(config.PythonAiSidecarPath))
|
||||
throw new FileNotFoundException($"Python sidecar not found: {config.PythonAiSidecarPath}");
|
||||
_client = new PythonSidecarClient(config);
|
||||
}
|
||||
|
||||
public async Task<SpeechDevicesResultDto> ListDevicesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var data = await _client.SendAsync("speech.devices.list", new { }, cancellationToken);
|
||||
if (data is null || data.Value.ValueKind != JsonValueKind.Object)
|
||||
return new SpeechDevicesResultDto([], "Unexpected speech device response from Python sidecar.");
|
||||
|
||||
var warning = data.Value.TryGetProperty("warning", out var warningNode)
|
||||
? warningNode.GetString()
|
||||
: null;
|
||||
|
||||
var devices = new List<SpeechDeviceDto>();
|
||||
if (data.Value.TryGetProperty("devices", out var devicesNode) && devicesNode.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var device in devicesNode.EnumerateArray())
|
||||
{
|
||||
if (device.ValueKind != JsonValueKind.Object)
|
||||
continue;
|
||||
|
||||
var index = device.TryGetProperty("index", out var indexNode) && indexNode.ValueKind == JsonValueKind.Number
|
||||
? indexNode.GetInt32()
|
||||
: -1;
|
||||
var name = device.TryGetProperty("name", out var nameNode)
|
||||
? nameNode.GetString() ?? ""
|
||||
: "";
|
||||
devices.Add(new SpeechDeviceDto(index, name));
|
||||
}
|
||||
}
|
||||
|
||||
return new SpeechDevicesResultDto(devices, warning);
|
||||
}
|
||||
|
||||
public async Task<SpeechTranscribeResultDto> TranscribeAsync(
|
||||
SpeechTranscribeRequestDto request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var data = await _client.SendAsync("speech.transcribe", new
|
||||
{
|
||||
audio_base64 = request.AudioBase64,
|
||||
engine = request.Engine,
|
||||
whisper_model = request.WhisperModel,
|
||||
text = request.Text,
|
||||
simulate_delay_ms = request.SimulateDelayMs
|
||||
}, cancellationToken);
|
||||
|
||||
if (data is null || data.Value.ValueKind != JsonValueKind.Object)
|
||||
throw new InvalidOperationException("Python sidecar speech response must be a JSON object.");
|
||||
|
||||
var text = data.Value.TryGetProperty("text", out var textNode)
|
||||
? textNode.GetString() ?? ""
|
||||
: "";
|
||||
var engine = data.Value.TryGetProperty("engine", out var engineNode)
|
||||
? engineNode.GetString() ?? (request.Engine ?? "whisper")
|
||||
: (request.Engine ?? "whisper");
|
||||
var warning = data.Value.TryGetProperty("warning", out var warningNode)
|
||||
? warningNode.GetString()
|
||||
: null;
|
||||
return new SpeechTranscribeResultDto(text, engine, warning);
|
||||
}
|
||||
}
|
||||
@ -11,8 +11,8 @@ global using Journal.Core.Services.Database;
|
||||
global using Journal.Core.Services.Entries;
|
||||
global using Journal.Core.Services.Fragments;
|
||||
global using Journal.Core.Services.Logging;
|
||||
global using Journal.Core.Services.Speech;
|
||||
global using Journal.Core.Services.Sidecar;
|
||||
global using Journal.Core.Services.Speech;
|
||||
global using Journal.Core.Services.Lists;
|
||||
global using Journal.Core.Services.Todos;
|
||||
global using Journal.Core.Services.Conversations;
|
||||
|
||||
@ -130,213 +130,5 @@ internal static partial class Program
|
||||
var warning = data.TryGetProperty("Warning", out var warningNode) ? warningNode.GetString() ?? "" : "";
|
||||
Assert(warning.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled speech warning.");
|
||||
}
|
||||
|
||||
static async Task TestPythonSidecarAiServiceJsonLineAsync()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "journal-ai-smoke", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
var scriptPath = Path.Combine(root, "fake_ai_sidecar.py");
|
||||
File.WriteAllText(scriptPath, """
|
||||
import json, sys
|
||||
request = json.loads(sys.stdin.readline())
|
||||
action = request.get("action", "")
|
||||
print("DEBUG prelude")
|
||||
if action == "health":
|
||||
print(json.dumps({"ok": True, "data": {"provider": "python-sidecar", "healthy": True, "message": "ok"}}))
|
||||
elif action == "summarize_entry":
|
||||
payload = request.get("payload") or {}
|
||||
print(json.dumps({"ok": True, "data": "ENTRY::" + str(payload.get("content", ""))}))
|
||||
elif action == "summarize_all":
|
||||
payload = request.get("payload") or {}
|
||||
entries = payload.get("entries") or []
|
||||
print(json.dumps({"ok": True, "data": "ALL::" + str(len(entries))}))
|
||||
elif action == "chat":
|
||||
payload = request.get("payload") or {}
|
||||
print(json.dumps({"ok": True, "data": "CHAT::" + str(payload.get("prompt", ""))}))
|
||||
elif action == "embed":
|
||||
payload = request.get("payload") or {}
|
||||
text = str(payload.get("content", ""))
|
||||
print(json.dumps({"ok": True, "data": [float(len(text)), 2.5, -1.0]}))
|
||||
else:
|
||||
print(json.dumps({"ok": False, "error": "unknown action"}))
|
||||
""");
|
||||
|
||||
try
|
||||
{
|
||||
var config = BuildAiConfig(scriptPath, timeoutMs: 4000);
|
||||
IAiService service = new PythonSidecarAiService(config);
|
||||
|
||||
var health = await service.HealthAsync();
|
||||
Assert(health.Enabled, "Expected enabled=true for python-sidecar health.");
|
||||
Assert(health.Healthy, "Expected healthy=true from fake sidecar health.");
|
||||
|
||||
var one = await service.SummarizeEntryAsync("hello");
|
||||
Assert(one == "ENTRY::hello", "Unexpected summarize_entry response.");
|
||||
|
||||
var all = await service.SummarizeAllAsync(["a", "b", "c"]);
|
||||
Assert(all == "ALL::3", "Unexpected summarize_all response.");
|
||||
|
||||
var chat = await service.ChatAsync("hello");
|
||||
Assert(chat == "CHAT::hello", "Unexpected chat response.");
|
||||
|
||||
var vector = await service.EmbedAsync("hello");
|
||||
Assert(vector.Count == 3, "Unexpected embed vector length.");
|
||||
Assert(Math.Abs(vector[0] - 5d) < 0.0001d, "Unexpected embed vector first value.");
|
||||
Assert(Math.Abs(vector[1] - 2.5d) < 0.0001d, "Unexpected embed vector second value.");
|
||||
Assert(Math.Abs(vector[2] + 1.0d) < 0.0001d, "Unexpected embed vector third value.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task TestPythonSidecarAiServiceErrorAsync()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "journal-ai-smoke", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
var scriptPath = Path.Combine(root, "fake_ai_sidecar_error.py");
|
||||
File.WriteAllText(scriptPath, """
|
||||
import json, sys
|
||||
_ = json.loads(sys.stdin.readline())
|
||||
print(json.dumps({"ok": False, "error": "simulated failure"}))
|
||||
""");
|
||||
|
||||
try
|
||||
{
|
||||
var config = BuildAiConfig(scriptPath, timeoutMs: 4000);
|
||||
IAiService service = new PythonSidecarAiService(config);
|
||||
|
||||
try
|
||||
{
|
||||
_ = await service.SummarizeEntryAsync("hello");
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("simulated failure", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Expected summarize_entry to surface sidecar error.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task TestPythonSidecarSpeechServiceNoDevicesAsync()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "journal-speech-smoke", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
var scriptPath = Path.Combine(root, "fake_speech_sidecar_nodes.py");
|
||||
File.WriteAllText(scriptPath, """
|
||||
import json, sys
|
||||
request = json.loads(sys.stdin.readline())
|
||||
action = request.get("action", "")
|
||||
if action == "speech.devices.list":
|
||||
print(json.dumps({"ok": True, "data": {"devices": [], "warning": "no devices"}}))
|
||||
elif action == "speech.transcribe":
|
||||
payload = request.get("payload") or {}
|
||||
print(json.dumps({"ok": True, "data": {"text": payload.get("text", ""), "engine": payload.get("engine", "whisper")}}))
|
||||
else:
|
||||
print(json.dumps({"ok": False, "error": "unknown action"}))
|
||||
""");
|
||||
|
||||
try
|
||||
{
|
||||
var config = BuildAiConfig(scriptPath, timeoutMs: 4000);
|
||||
ISpeechBridgeService service = new PythonSidecarSpeechService(config);
|
||||
|
||||
var devices = await service.ListDevicesAsync();
|
||||
Assert(devices.Devices.Count == 0, "Expected empty devices list.");
|
||||
Assert((devices.Warning ?? "").Contains("no devices", StringComparison.OrdinalIgnoreCase), "Expected no-devices warning.");
|
||||
|
||||
var transcript = await service.TranscribeAsync(new SpeechTranscribeRequestDto(Text: "fixture text", Engine: "whisper"));
|
||||
Assert(transcript.Text == "fixture text", "Expected passthrough transcript text.");
|
||||
Assert(transcript.Engine == "whisper", "Expected passthrough transcript engine.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task TestPythonSidecarSpeechServiceErrorAsync()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "journal-speech-smoke", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
var scriptPath = Path.Combine(root, "fake_speech_sidecar_error.py");
|
||||
File.WriteAllText(scriptPath, """
|
||||
import json, sys
|
||||
request = json.loads(sys.stdin.readline())
|
||||
action = request.get("action", "")
|
||||
if action == "speech.transcribe":
|
||||
print(json.dumps({"ok": False, "error": "engine unavailable"}))
|
||||
else:
|
||||
print(json.dumps({"ok": True, "data": {"devices": []}}))
|
||||
""");
|
||||
|
||||
try
|
||||
{
|
||||
var config = BuildAiConfig(scriptPath, timeoutMs: 4000);
|
||||
ISpeechBridgeService service = new PythonSidecarSpeechService(config);
|
||||
|
||||
try
|
||||
{
|
||||
_ = await service.TranscribeAsync(new SpeechTranscribeRequestDto(Text: "fixture", Engine: "faster-whisper"));
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("engine unavailable", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Expected speech transcribe to surface sidecar engine error.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task TestPythonSidecarSpeechServiceTimeoutAsync()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "journal-speech-smoke", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
var scriptPath = Path.Combine(root, "fake_speech_sidecar_timeout.py");
|
||||
File.WriteAllText(scriptPath, """
|
||||
import json, sys, time
|
||||
request = json.loads(sys.stdin.readline())
|
||||
payload = request.get("payload") or {}
|
||||
sleep_ms = int(payload.get("simulate_delay_ms") or 0)
|
||||
time.sleep(max(0, sleep_ms) / 1000.0)
|
||||
print(json.dumps({"ok": True, "data": {"text": "", "engine": "whisper"}}))
|
||||
""");
|
||||
|
||||
try
|
||||
{
|
||||
var config = BuildAiConfig(scriptPath, timeoutMs: 100);
|
||||
ISpeechBridgeService service = new PythonSidecarSpeechService(config);
|
||||
|
||||
try
|
||||
{
|
||||
_ = await service.TranscribeAsync(new SpeechTranscribeRequestDto(Text: "fixture", SimulateDelayMs: 500));
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Expected speech transcribe timeout path.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -120,9 +120,6 @@ internal static partial class Program
|
||||
Assert(current.SpeechRecognitionEngine == "whisper", "Config SpeechRecognitionEngine default mismatch.");
|
||||
Assert(current.WhisperModelSize == "base", "Config WhisperModelSize default mismatch.");
|
||||
Assert(current.AiProvider == "none", "Config AiProvider default mismatch.");
|
||||
Assert(current.PythonExecutable == "python", "Config PythonExecutable default mismatch.");
|
||||
Assert(current.AiSidecarTimeoutMs == 45000, "Config AiSidecarTimeoutMs default mismatch.");
|
||||
Assert(current.PythonAiSidecarPath.EndsWith(Path.Combine("journal", "ai", "sidecar.py"), StringComparison.OrdinalIgnoreCase), "Config PythonAiSidecarPath default mismatch.");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@ -160,22 +160,6 @@ fragment three
|
||||
}
|
||||
}
|
||||
|
||||
static JournalConfig BuildAiConfig(string sidecarScriptPath, int timeoutMs)
|
||||
{
|
||||
var baseConfig = new JournalConfigService().Current;
|
||||
var pythonExe = Environment.GetEnvironmentVariable("JOURNAL_PYTHON_EXE");
|
||||
if (string.IsNullOrWhiteSpace(pythonExe))
|
||||
pythonExe = "python";
|
||||
|
||||
return baseConfig with
|
||||
{
|
||||
AiProvider = "python-sidecar",
|
||||
PythonExecutable = pythonExe,
|
||||
PythonAiSidecarPath = sidecarScriptPath,
|
||||
AiSidecarTimeoutMs = timeoutMs
|
||||
};
|
||||
}
|
||||
|
||||
static async Task<List<TransportFixture>> LoadTransportFixturesAsync()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "transport_cases.json");
|
||||
|
||||
@ -63,11 +63,6 @@ internal static partial class Program
|
||||
("Entry ai.embed returns empty vector when disabled", TestEntryAiEmbedDisabledAsync),
|
||||
("Entry speech.devices.list returns envelope when disabled", TestEntrySpeechDevicesListDisabledAsync),
|
||||
("Entry speech.transcribe returns envelope when disabled", TestEntrySpeechTranscribeDisabledAsync),
|
||||
("Python sidecar AI service parses last JSON line", TestPythonSidecarAiServiceJsonLineAsync),
|
||||
("Python sidecar AI service surfaces sidecar errors", TestPythonSidecarAiServiceErrorAsync),
|
||||
("Python sidecar speech service handles empty devices payload", TestPythonSidecarSpeechServiceNoDevicesAsync),
|
||||
("Python sidecar speech service surfaces unavailable engine errors", TestPythonSidecarSpeechServiceErrorAsync),
|
||||
("Python sidecar speech service times out deterministically", TestPythonSidecarSpeechServiceTimeoutAsync),
|
||||
("Entry vault.load_all succeeds for empty vault directory", TestEntryVaultLoadAllEmptyAsync),
|
||||
("Entry vault.load_all wrong password keeps database session locked", TestEntryVaultLoadWrongPasswordKeepsSessionLockedAsync),
|
||||
("Entry vault.clear_data_directory compatibility command succeeds", TestEntryVaultClearDataDirectoryAsync),
|
||||
|
||||