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>
This commit is contained in:
Jacob Schmidt 2026-03-01 17:37:45 -06:00
parent c074036607
commit 2cd31e6fb1
64 changed files with 14 additions and 580 deletions

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 995 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 935 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -22,7 +22,4 @@ public sealed record JournalConfig(
string WhisperModelSize,
string NlpBackend,
string AiProvider,
string PythonExecutable,
string PythonAiSidecarPath,
int AiSidecarTimeoutMs,
string GgufModelPath);

View File

@ -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>();

View File

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

View File

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

View File

@ -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.
}
}
}

View File

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

View File

@ -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;

View File

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

View File

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

View File

@ -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");

View File

@ -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),