refactor: slim Entry.cs, extract shared sidecar client, add entry file repository
- Slim Entry.cs from ~550 to ~330 lines (thin dispatcher only) - Extract HtmlSanitizer, CommandLogger, EntryFileService from Entry.cs - Extract PythonSidecarClient from duplicated sidecar plumbing - Add IEntryFileRepository + DiskEntryFileRepository (mirrors Fragment pattern) - Move payload records to Dtos/CommandDtos.cs - Move database result records to Dtos/DatabaseDtos.cs - Register new services and repository in DI - All 70/70 smoke tests pass, no behavior changes Co-Authored-By: Warp <agent@warp.dev>
This commit is contained in:
parent
14b8e7a339
commit
d3781d6c3e
35
Journal.Core/Dtos/CommandDtos.cs
Normal file
35
Journal.Core/Dtos/CommandDtos.cs
Normal file
@ -0,0 +1,35 @@
|
||||
namespace Journal.Core.Dtos;
|
||||
|
||||
internal sealed record VaultInitializePayload(string Password, string VaultDirectory);
|
||||
internal sealed record VaultPayload(string Password, string VaultDirectory, string DataDirectory, string? NowUtc = null);
|
||||
internal sealed record ClearDataPayload(string DataDirectory);
|
||||
internal sealed record EntryListPayload(string? DataDirectory = null);
|
||||
internal sealed record EntryLoadPayload(string FilePath);
|
||||
public sealed record EntrySavePayload(string Content, string? FilePath = null, string? Mode = null);
|
||||
public sealed record EntryListItem(string FileName, string FilePath);
|
||||
public sealed record EntryLoadResult(string Date, string FileName, string FilePath, string RawContent);
|
||||
public sealed record EntrySaveResult(string FilePath);
|
||||
internal sealed record DatabasePayload(string Password, string? DataDirectory = null);
|
||||
internal sealed record AiSummarizeEntryPayload(string Content, string? FileStem = null);
|
||||
internal sealed record AiSummarizeAllPayload(List<string>? Entries);
|
||||
internal sealed record AiChatPayload(string Prompt);
|
||||
internal sealed record AiEmbedPayload(string Content);
|
||||
internal sealed record SpeechTranscribePayload(
|
||||
string? AudioBase64 = null,
|
||||
string? Audio_Base64 = null,
|
||||
string? Engine = null,
|
||||
string? WhisperModel = null,
|
||||
string? Whisper_Model = null,
|
||||
string? Text = null,
|
||||
int? SimulateDelayMs = null,
|
||||
int? Simulate_Delay_Ms = null);
|
||||
internal sealed record SearchEntriesPayload(
|
||||
string DataDirectory,
|
||||
string? Query = null,
|
||||
string? Section = null,
|
||||
string? StartDate = null,
|
||||
string? EndDate = null,
|
||||
List<string>? Tags = null,
|
||||
List<string>? Types = null,
|
||||
List<string>? Checked = null,
|
||||
List<string>? Unchecked = null);
|
||||
18
Journal.Core/Dtos/DatabaseDtos.cs
Normal file
18
Journal.Core/Dtos/DatabaseDtos.cs
Normal file
@ -0,0 +1,18 @@
|
||||
namespace Journal.Core.Dtos;
|
||||
|
||||
public sealed record JournalDatabaseStatus(
|
||||
string DatabasePath,
|
||||
int KeyLengthBytes,
|
||||
int Iterations,
|
||||
string KeyDerivation,
|
||||
IReadOnlyList<string> SchemaTables,
|
||||
string SchemaBootstrapPath,
|
||||
bool RuntimeReady,
|
||||
string RuntimeMessage);
|
||||
|
||||
public sealed record JournalDatabaseHydrationResult(
|
||||
string DatabasePath,
|
||||
string SchemaBootstrapPath,
|
||||
int EntryFilesProcessed,
|
||||
bool RuntimeReady,
|
||||
string Message);
|
||||
@ -1,7 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Text.Json;
|
||||
using Journal.Core.Dtos;
|
||||
using Journal.Core.Models;
|
||||
@ -18,6 +16,8 @@ public class Entry
|
||||
private readonly IJournalConfigService _config;
|
||||
private readonly IAiService _ai;
|
||||
private readonly ISpeechBridgeService _speech;
|
||||
private readonly IEntryFileService _entryFiles;
|
||||
private readonly CommandLogger _logger;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
@ -30,7 +30,9 @@ public class Entry
|
||||
IJournalDatabaseService database,
|
||||
IJournalConfigService config,
|
||||
IAiService ai,
|
||||
ISpeechBridgeService speech)
|
||||
ISpeechBridgeService speech,
|
||||
IEntryFileService entryFiles,
|
||||
CommandLogger logger)
|
||||
{
|
||||
_fragments = fragments;
|
||||
_entrySearch = entrySearch;
|
||||
@ -39,6 +41,8 @@ public class Entry
|
||||
_config = config;
|
||||
_ai = ai;
|
||||
_speech = speech;
|
||||
_entryFiles = entryFiles;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task RunAsync()
|
||||
@ -73,7 +77,7 @@ public class Entry
|
||||
var correlationId = string.IsNullOrWhiteSpace(cmd.CorrelationId)
|
||||
? Guid.NewGuid().ToString("N")
|
||||
: cmd.CorrelationId.Trim();
|
||||
LogStart(action, correlationId, cmd.Payload);
|
||||
_logger.LogStart(action, correlationId, cmd.Payload);
|
||||
object? result;
|
||||
|
||||
try
|
||||
@ -131,19 +135,19 @@ public class Entry
|
||||
var listDataDirectory = !string.IsNullOrWhiteSpace(listPayload?.DataDirectory)
|
||||
? listPayload.DataDirectory
|
||||
: _config.Current.DataDirectory;
|
||||
result = ListEntries(listDataDirectory);
|
||||
result = _entryFiles.ListEntries(listDataDirectory);
|
||||
break;
|
||||
case "entries.load":
|
||||
var loadEntryPayload = DeserializePayload<EntryLoadPayload>(cmd.Payload);
|
||||
if (loadEntryPayload is null || string.IsNullOrWhiteSpace(loadEntryPayload.FilePath))
|
||||
return Error("Missing or invalid payload");
|
||||
result = LoadEntry(loadEntryPayload.FilePath);
|
||||
result = _entryFiles.LoadEntry(loadEntryPayload.FilePath);
|
||||
break;
|
||||
case "entries.save":
|
||||
var saveEntryPayload = DeserializePayload<EntrySavePayload>(cmd.Payload);
|
||||
if (saveEntryPayload is null || string.IsNullOrWhiteSpace(saveEntryPayload.Content))
|
||||
return Error("Missing or invalid payload");
|
||||
result = SaveEntry(saveEntryPayload, _config.Current.DataDirectory);
|
||||
result = _entryFiles.SaveEntry(saveEntryPayload, _config.Current.DataDirectory);
|
||||
break;
|
||||
case "config.get":
|
||||
result = _config.Current;
|
||||
@ -256,122 +260,53 @@ public class Entry
|
||||
result = _database.HydrateWorkspace(dbHydratePayload.Password, dbHydratePayload.DataDirectory);
|
||||
break;
|
||||
default:
|
||||
LogFailure(action, correlationId, "unknown_action");
|
||||
_logger.LogFailure(action, correlationId, "unknown_action");
|
||||
return Error($"Unknown action: {action}");
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
LogFailure(action, correlationId, "invalid_payload_json");
|
||||
_logger.LogFailure(action, correlationId, "invalid_payload_json");
|
||||
return Error("Missing or invalid payload");
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
LogFailure(action, correlationId, "validation", ex.Message);
|
||||
_logger.LogFailure(action, correlationId, "validation", ex.Message);
|
||||
return Error(ex.Message);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
LogFailure(action, correlationId, "argument", ex.Message);
|
||||
_logger.LogFailure(action, correlationId, "argument", ex.Message);
|
||||
return Error(ex.Message);
|
||||
}
|
||||
catch (TimeoutException ex)
|
||||
{
|
||||
LogFailure(action, correlationId, "timeout", ex.Message);
|
||||
_logger.LogFailure(action, correlationId, "timeout", ex.Message);
|
||||
return Error(ex.Message);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
LogFailure(action, correlationId, "invalid_operation", ex.Message);
|
||||
_logger.LogFailure(action, correlationId, "invalid_operation", ex.Message);
|
||||
return Error(ex.Message);
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
LogFailure(action, correlationId, "not_found", ex.Message);
|
||||
_logger.LogFailure(action, correlationId, "not_found", ex.Message);
|
||||
return Error(ex.Message);
|
||||
}
|
||||
catch
|
||||
{
|
||||
LogFailure(action, correlationId, "internal_error");
|
||||
_logger.LogFailure(action, correlationId, "internal_error");
|
||||
return Error("Internal error");
|
||||
}
|
||||
|
||||
LogSuccess(action, correlationId);
|
||||
_logger.LogSuccess(action, correlationId);
|
||||
return JsonSerializer.Serialize(new { ok = true, data = result });
|
||||
}
|
||||
|
||||
private static string Error(string message)
|
||||
=> JsonSerializer.Serialize(new { ok = false, error = message });
|
||||
|
||||
private void LogStart(string action, string correlationId, JsonElement? payload)
|
||||
{
|
||||
var redactedPayload = LogRedactor.RedactPayload(payload);
|
||||
EmitLog("information", action, correlationId, "start", redactedPayload);
|
||||
}
|
||||
|
||||
private void LogSuccess(string action, string correlationId)
|
||||
{
|
||||
EmitLog("information", action, correlationId, "success");
|
||||
}
|
||||
|
||||
private void LogFailure(string action, string correlationId, string errorType, string? message = null)
|
||||
{
|
||||
var details = string.IsNullOrWhiteSpace(message)
|
||||
? ""
|
||||
: (message!.Length <= 160 ? message : $"{message[..160]}...(truncated)");
|
||||
EmitLog("warning", action, correlationId, "failure", errorType: errorType, details: details);
|
||||
}
|
||||
|
||||
private static void EmitLog(
|
||||
string level,
|
||||
string action,
|
||||
string correlationId,
|
||||
string outcome,
|
||||
object? payload = null,
|
||||
string? errorType = null,
|
||||
string? details = null)
|
||||
{
|
||||
if (!ShouldLog(level))
|
||||
return;
|
||||
|
||||
var envelope = new
|
||||
{
|
||||
timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture),
|
||||
level,
|
||||
component = nameof(Entry),
|
||||
action,
|
||||
correlation_id = correlationId,
|
||||
outcome,
|
||||
error_type = errorType,
|
||||
details,
|
||||
payload
|
||||
};
|
||||
Console.Error.WriteLine(JsonSerializer.Serialize(envelope));
|
||||
}
|
||||
|
||||
private static bool ShouldLog(string level)
|
||||
{
|
||||
var configured = (Environment.GetEnvironmentVariable("JOURNAL_LOG_LEVEL") ?? "warning")
|
||||
.Trim()
|
||||
.ToLowerInvariant();
|
||||
var configuredRank = LogLevelRank(configured);
|
||||
var incomingRank = LogLevelRank(level);
|
||||
return incomingRank >= configuredRank;
|
||||
}
|
||||
|
||||
private static int LogLevelRank(string level) => level switch
|
||||
{
|
||||
"trace" => 0,
|
||||
"debug" => 1,
|
||||
"information" => 2,
|
||||
"info" => 2,
|
||||
"warning" => 3,
|
||||
"warn" => 3,
|
||||
"error" => 4,
|
||||
"critical" => 5,
|
||||
_ => 3
|
||||
};
|
||||
|
||||
private static T? DeserializePayload<T>(JsonElement? payload)
|
||||
{
|
||||
if (payload is null)
|
||||
@ -395,153 +330,4 @@ public class Entry
|
||||
|
||||
throw new ArgumentException("Invalid nowUtc value. Expected ISO date/time.");
|
||||
}
|
||||
|
||||
private static IReadOnlyList<EntryListItem> ListEntries(string dataDirectory)
|
||||
{
|
||||
if (!Directory.Exists(dataDirectory))
|
||||
return [];
|
||||
|
||||
return Directory.GetFiles(dataDirectory, "*.md")
|
||||
.OrderBy(Path.GetFileName, StringComparer.Ordinal)
|
||||
.Select(path => new EntryListItem(
|
||||
FileName: Path.GetFileName(path),
|
||||
FilePath: Path.GetFullPath(path)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static EntryLoadResult LoadEntry(string filePath)
|
||||
{
|
||||
var normalizedPath = Path.GetFullPath(filePath);
|
||||
if (!File.Exists(normalizedPath))
|
||||
throw new FileNotFoundException($"Entry file not found: {normalizedPath}");
|
||||
|
||||
var rawContent = StripRichHtml(File.ReadAllText(normalizedPath));
|
||||
var fileStem = Path.GetFileNameWithoutExtension(normalizedPath);
|
||||
var entry = JournalParser.ParseJournalContent(rawContent, fileStem);
|
||||
|
||||
return new EntryLoadResult(
|
||||
Date: entry.Date,
|
||||
FileName: Path.GetFileName(normalizedPath),
|
||||
FilePath: normalizedPath,
|
||||
RawContent: entry.RawContent);
|
||||
}
|
||||
|
||||
private static EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory)
|
||||
{
|
||||
var targetPath = ResolveTargetPath(payload.FilePath, defaultDataDirectory);
|
||||
var mode = string.IsNullOrWhiteSpace(payload.Mode) ? "Daily" : payload.Mode.Trim();
|
||||
var sanitizedContent = StripRichHtml(payload.Content ?? "");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
||||
|
||||
if (string.Equals(mode, "Overwrite", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.WriteAllText(targetPath, sanitizedContent);
|
||||
return new EntrySaveResult(targetPath);
|
||||
}
|
||||
|
||||
if (string.Equals(mode, "Fragment", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.AppendAllText(targetPath, "\n\n" + sanitizedContent.Trim());
|
||||
return new EntrySaveResult(targetPath);
|
||||
}
|
||||
|
||||
string finalContent;
|
||||
if (File.Exists(targetPath))
|
||||
{
|
||||
var existingContent = File.ReadAllText(targetPath);
|
||||
var fileStem = Path.GetFileNameWithoutExtension(targetPath);
|
||||
var existingEntry = JournalParser.ParseJournalContent(existingContent, fileStem);
|
||||
var newEntryData = JournalParser.ParseJournalContent(sanitizedContent, fileStem);
|
||||
existingEntry.MergeWith(newEntryData);
|
||||
finalContent = existingEntry.ToMarkdown();
|
||||
}
|
||||
else
|
||||
{
|
||||
finalContent = sanitizedContent;
|
||||
}
|
||||
|
||||
File.WriteAllText(targetPath, finalContent);
|
||||
return new EntrySaveResult(targetPath);
|
||||
}
|
||||
|
||||
private static string ResolveTargetPath(string? filePath, string defaultDataDirectory)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(filePath))
|
||||
return Path.GetFullPath(filePath);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(defaultDataDirectory, $"{DateTime.Now:yyyy-MM-dd}.md"));
|
||||
}
|
||||
|
||||
private static bool LooksLikeRichHtml(string content)
|
||||
{
|
||||
var lowered = content.ToLowerInvariant();
|
||||
string[] markers =
|
||||
[
|
||||
"<p", "</p>", "<div", "<span", "<table", "<tr", "<td", "<li", "<ul", "<ol",
|
||||
"style=", "font-family:", "-webkit-text-stroke"
|
||||
];
|
||||
if (markers.Any(marker => lowered.Contains(marker, StringComparison.Ordinal)))
|
||||
return true;
|
||||
return Regex.Matches(lowered, "</?[a-z][^>]*>").Count >= 8;
|
||||
}
|
||||
|
||||
private static string StripRichHtml(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return content;
|
||||
if (!LooksLikeRichHtml(content))
|
||||
return content;
|
||||
|
||||
var text = content.Replace("\r\n", "\n").Replace("\r", "\n");
|
||||
text = Regex.Replace(text, "<(script|style)\\b[^>]*>.*?</\\1>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
text = Regex.Replace(text, "<br\\s*/?>", "\n", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "</(p|div|h[1-6]|tr|table|ul|ol|blockquote)>", "\n", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "<li\\b[^>]*>", "\n- ", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "</li>", "\n", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "<(td|th)\\b[^>]*>", " | ", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "</(td|th)>", " ", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "<hr\\b[^>]*>", "\n---\n", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "<[^>]+>", "", RegexOptions.Singleline);
|
||||
text = WebUtility.HtmlDecode(text)
|
||||
.Replace('\u00a0', ' ')
|
||||
.Replace("\u200b", "", StringComparison.Ordinal);
|
||||
text = string.Join("\n", text.Split('\n').Select(line => line.TrimEnd()));
|
||||
text = Regex.Replace(text, "[ \\t]{2,}", " ");
|
||||
text = Regex.Replace(text, "\n{3,}", "\n\n").Trim();
|
||||
return string.IsNullOrEmpty(text) ? content : text;
|
||||
}
|
||||
|
||||
private sealed record VaultInitializePayload(string Password, string VaultDirectory);
|
||||
private sealed record VaultPayload(string Password, string VaultDirectory, string DataDirectory, string? NowUtc = null);
|
||||
private sealed record ClearDataPayload(string DataDirectory);
|
||||
private sealed record EntryListPayload(string? DataDirectory = null);
|
||||
private sealed record EntryLoadPayload(string FilePath);
|
||||
private sealed record EntrySavePayload(string Content, string? FilePath = null, string? Mode = null);
|
||||
private sealed record EntryListItem(string FileName, string FilePath);
|
||||
private sealed record EntryLoadResult(string Date, string FileName, string FilePath, string RawContent);
|
||||
private sealed record EntrySaveResult(string FilePath);
|
||||
private sealed record DatabasePayload(string Password, string? DataDirectory = null);
|
||||
private sealed record AiSummarizeEntryPayload(string Content, string? FileStem = null);
|
||||
private sealed record AiSummarizeAllPayload(List<string>? Entries);
|
||||
private sealed record AiChatPayload(string Prompt);
|
||||
private sealed record AiEmbedPayload(string Content);
|
||||
private sealed record SpeechTranscribePayload(
|
||||
string? AudioBase64 = null,
|
||||
string? Audio_Base64 = null,
|
||||
string? Engine = null,
|
||||
string? WhisperModel = null,
|
||||
string? Whisper_Model = null,
|
||||
string? Text = null,
|
||||
int? SimulateDelayMs = null,
|
||||
int? Simulate_Delay_Ms = null);
|
||||
private sealed record SearchEntriesPayload(
|
||||
string DataDirectory,
|
||||
string? Query = null,
|
||||
string? Section = null,
|
||||
string? StartDate = null,
|
||||
string? EndDate = null,
|
||||
List<string>? Tags = null,
|
||||
List<string>? Types = null,
|
||||
List<string>? Checked = null,
|
||||
List<string>? Unchecked = null);
|
||||
}
|
||||
|
||||
35
Journal.Core/Repositories/DiskEntryFileRepository.cs
Normal file
35
Journal.Core/Repositories/DiskEntryFileRepository.cs
Normal file
@ -0,0 +1,35 @@
|
||||
namespace Journal.Core.Repositories;
|
||||
|
||||
public sealed class DiskEntryFileRepository : IEntryFileRepository
|
||||
{
|
||||
public IReadOnlyList<string> ListMarkdownFiles(string dataDirectory)
|
||||
{
|
||||
if (!Directory.Exists(dataDirectory))
|
||||
return [];
|
||||
|
||||
return Directory.GetFiles(dataDirectory, "*.md")
|
||||
.OrderBy(Path.GetFileName, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public string ReadFile(string filePath) => File.ReadAllText(filePath);
|
||||
|
||||
public void WriteFile(string filePath, string content) => File.WriteAllText(filePath, content);
|
||||
|
||||
public void AppendFile(string filePath, string content) => File.AppendAllText(filePath, content);
|
||||
|
||||
public bool FileExists(string filePath) => File.Exists(filePath);
|
||||
|
||||
public string GetFullPath(string filePath) => Path.GetFullPath(filePath);
|
||||
|
||||
public string GetFileName(string filePath) => Path.GetFileName(filePath);
|
||||
|
||||
public string GetFileNameWithoutExtension(string filePath) => Path.GetFileNameWithoutExtension(filePath);
|
||||
|
||||
public void EnsureDirectory(string path)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrWhiteSpace(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
}
|
||||
14
Journal.Core/Repositories/IEntryFileRepository.cs
Normal file
14
Journal.Core/Repositories/IEntryFileRepository.cs
Normal file
@ -0,0 +1,14 @@
|
||||
namespace Journal.Core.Repositories;
|
||||
|
||||
public interface IEntryFileRepository
|
||||
{
|
||||
IReadOnlyList<string> ListMarkdownFiles(string dataDirectory);
|
||||
string ReadFile(string filePath);
|
||||
void WriteFile(string filePath, string content);
|
||||
void AppendFile(string filePath, string content);
|
||||
bool FileExists(string filePath);
|
||||
string GetFullPath(string filePath);
|
||||
string GetFileName(string filePath);
|
||||
string GetFileNameWithoutExtension(string filePath);
|
||||
void EnsureDirectory(string path);
|
||||
}
|
||||
@ -47,6 +47,9 @@ public static class ServiceCollectionExtensions
|
||||
message: $"Python speech sidecar unavailable: {ex.Message}");
|
||||
}
|
||||
});
|
||||
services.AddSingleton<IEntryFileRepository, DiskEntryFileRepository>();
|
||||
services.AddSingleton<IEntryFileService, EntryFileService>();
|
||||
services.AddSingleton<CommandLogger>();
|
||||
services.AddSingleton<SidecarCli>();
|
||||
return services;
|
||||
}
|
||||
|
||||
76
Journal.Core/Services/CommandLogger.cs
Normal file
76
Journal.Core/Services/CommandLogger.cs
Normal file
@ -0,0 +1,76 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public sealed class CommandLogger
|
||||
{
|
||||
public void LogStart(string action, string correlationId, JsonElement? payload)
|
||||
{
|
||||
var redactedPayload = LogRedactor.RedactPayload(payload);
|
||||
EmitLog("information", action, correlationId, "start", redactedPayload);
|
||||
}
|
||||
|
||||
public void LogSuccess(string action, string correlationId)
|
||||
{
|
||||
EmitLog("information", action, correlationId, "success");
|
||||
}
|
||||
|
||||
public void LogFailure(string action, string correlationId, string errorType, string? message = null)
|
||||
{
|
||||
var details = string.IsNullOrWhiteSpace(message)
|
||||
? ""
|
||||
: (message!.Length <= 160 ? message : $"{message[..160]}...(truncated)");
|
||||
EmitLog("warning", action, correlationId, "failure", errorType: errorType, details: details);
|
||||
}
|
||||
|
||||
private static void EmitLog(
|
||||
string level,
|
||||
string action,
|
||||
string correlationId,
|
||||
string outcome,
|
||||
object? payload = null,
|
||||
string? errorType = null,
|
||||
string? details = null)
|
||||
{
|
||||
if (!ShouldLog(level))
|
||||
return;
|
||||
|
||||
var envelope = new
|
||||
{
|
||||
timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture),
|
||||
level,
|
||||
component = "Entry",
|
||||
action,
|
||||
correlation_id = correlationId,
|
||||
outcome,
|
||||
error_type = errorType,
|
||||
details,
|
||||
payload
|
||||
};
|
||||
Console.Error.WriteLine(JsonSerializer.Serialize(envelope));
|
||||
}
|
||||
|
||||
private static bool ShouldLog(string level)
|
||||
{
|
||||
var configured = (Environment.GetEnvironmentVariable("JOURNAL_LOG_LEVEL") ?? "warning")
|
||||
.Trim()
|
||||
.ToLowerInvariant();
|
||||
var configuredRank = LogLevelRank(configured);
|
||||
var incomingRank = LogLevelRank(level);
|
||||
return incomingRank >= configuredRank;
|
||||
}
|
||||
|
||||
private static int LogLevelRank(string level) => level switch
|
||||
{
|
||||
"trace" => 0,
|
||||
"debug" => 1,
|
||||
"information" => 2,
|
||||
"info" => 2,
|
||||
"warning" => 3,
|
||||
"warn" => 3,
|
||||
"error" => 4,
|
||||
"critical" => 5,
|
||||
_ => 3
|
||||
};
|
||||
}
|
||||
84
Journal.Core/Services/EntryFileService.cs
Normal file
84
Journal.Core/Services/EntryFileService.cs
Normal file
@ -0,0 +1,84 @@
|
||||
using Journal.Core.Dtos;
|
||||
using Journal.Core.Repositories;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public sealed class EntryFileService : IEntryFileService
|
||||
{
|
||||
private readonly IEntryFileRepository _repo;
|
||||
|
||||
public EntryFileService(IEntryFileRepository repo) =>
|
||||
_repo = repo ?? throw new ArgumentNullException(nameof(repo));
|
||||
|
||||
public IReadOnlyList<EntryListItem> ListEntries(string dataDirectory)
|
||||
{
|
||||
return _repo.ListMarkdownFiles(dataDirectory)
|
||||
.Select(path => new EntryListItem(
|
||||
FileName: _repo.GetFileName(path),
|
||||
FilePath: _repo.GetFullPath(path)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public EntryLoadResult LoadEntry(string filePath)
|
||||
{
|
||||
var normalizedPath = _repo.GetFullPath(filePath);
|
||||
if (!_repo.FileExists(normalizedPath))
|
||||
throw new FileNotFoundException($"Entry file not found: {normalizedPath}");
|
||||
|
||||
var rawContent = HtmlSanitizer.StripRichHtml(_repo.ReadFile(normalizedPath));
|
||||
var fileStem = _repo.GetFileNameWithoutExtension(normalizedPath);
|
||||
var entry = JournalParser.ParseJournalContent(rawContent, fileStem);
|
||||
|
||||
return new EntryLoadResult(
|
||||
Date: entry.Date,
|
||||
FileName: _repo.GetFileName(normalizedPath),
|
||||
FilePath: normalizedPath,
|
||||
RawContent: entry.RawContent);
|
||||
}
|
||||
|
||||
public EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory)
|
||||
{
|
||||
var targetPath = ResolveTargetPath(payload.FilePath, defaultDataDirectory);
|
||||
var mode = string.IsNullOrWhiteSpace(payload.Mode) ? "Daily" : payload.Mode.Trim();
|
||||
var sanitizedContent = HtmlSanitizer.StripRichHtml(payload.Content ?? "");
|
||||
_repo.EnsureDirectory(targetPath);
|
||||
|
||||
if (string.Equals(mode, "Overwrite", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_repo.WriteFile(targetPath, sanitizedContent);
|
||||
return new EntrySaveResult(targetPath);
|
||||
}
|
||||
|
||||
if (string.Equals(mode, "Fragment", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_repo.AppendFile(targetPath, "\n\n" + sanitizedContent.Trim());
|
||||
return new EntrySaveResult(targetPath);
|
||||
}
|
||||
|
||||
string finalContent;
|
||||
if (_repo.FileExists(targetPath))
|
||||
{
|
||||
var existingContent = _repo.ReadFile(targetPath);
|
||||
var fileStem = _repo.GetFileNameWithoutExtension(targetPath);
|
||||
var existingEntry = JournalParser.ParseJournalContent(existingContent, fileStem);
|
||||
var newEntryData = JournalParser.ParseJournalContent(sanitizedContent, fileStem);
|
||||
existingEntry.MergeWith(newEntryData);
|
||||
finalContent = existingEntry.ToMarkdown();
|
||||
}
|
||||
else
|
||||
{
|
||||
finalContent = sanitizedContent;
|
||||
}
|
||||
|
||||
_repo.WriteFile(targetPath, finalContent);
|
||||
return new EntrySaveResult(targetPath);
|
||||
}
|
||||
|
||||
private string ResolveTargetPath(string? filePath, string defaultDataDirectory)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(filePath))
|
||||
return _repo.GetFullPath(filePath);
|
||||
|
||||
return _repo.GetFullPath(Path.Combine(defaultDataDirectory, $"{DateTime.Now:yyyy-MM-dd}.md"));
|
||||
}
|
||||
}
|
||||
46
Journal.Core/Services/HtmlSanitizer.cs
Normal file
46
Journal.Core/Services/HtmlSanitizer.cs
Normal file
@ -0,0 +1,46 @@
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public static class HtmlSanitizer
|
||||
{
|
||||
public static string StripRichHtml(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return content;
|
||||
if (!LooksLikeRichHtml(content))
|
||||
return content;
|
||||
|
||||
var text = content.Replace("\r\n", "\n").Replace("\r", "\n");
|
||||
text = Regex.Replace(text, "<(script|style)\\b[^>]*>.*?</\\1>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
text = Regex.Replace(text, "<br\\s*/?>", "\n", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "</(p|div|h[1-6]|tr|table|ul|ol|blockquote)>", "\n", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "<li\\b[^>]*>", "\n- ", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "</li>", "\n", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "<(td|th)\\b[^>]*>", " | ", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "</(td|th)>", " ", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "<hr\\b[^>]*>", "\n---\n", RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, "<[^>]+>", "", RegexOptions.Singleline);
|
||||
text = WebUtility.HtmlDecode(text)
|
||||
.Replace('\u00a0', ' ')
|
||||
.Replace("\u200b", "", StringComparison.Ordinal);
|
||||
text = string.Join("\n", text.Split('\n').Select(line => line.TrimEnd()));
|
||||
text = Regex.Replace(text, "[ \\t]{2,}", " ");
|
||||
text = Regex.Replace(text, "\n{3,}", "\n\n").Trim();
|
||||
return string.IsNullOrEmpty(text) ? content : text;
|
||||
}
|
||||
|
||||
public static bool LooksLikeRichHtml(string content)
|
||||
{
|
||||
var lowered = content.ToLowerInvariant();
|
||||
string[] markers =
|
||||
[
|
||||
"<p", "</p>", "<div", "<span", "<table", "<tr", "<td", "<li", "<ul", "<ol",
|
||||
"style=", "font-family:", "-webkit-text-stroke"
|
||||
];
|
||||
if (markers.Any(marker => lowered.Contains(marker, StringComparison.Ordinal)))
|
||||
return true;
|
||||
return Regex.Matches(lowered, "</?[a-z][^>]*>").Count >= 8;
|
||||
}
|
||||
}
|
||||
10
Journal.Core/Services/IEntryFileService.cs
Normal file
10
Journal.Core/Services/IEntryFileService.cs
Normal file
@ -0,0 +1,10 @@
|
||||
using Journal.Core.Dtos;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public interface IEntryFileService
|
||||
{
|
||||
IReadOnlyList<EntryListItem> ListEntries(string dataDirectory);
|
||||
EntryLoadResult LoadEntry(string filePath);
|
||||
EntrySaveResult SaveEntry(EntrySavePayload payload, string defaultDataDirectory);
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
using Journal.Core.Dtos;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public interface IJournalDatabaseService
|
||||
@ -10,20 +12,3 @@ public interface IJournalDatabaseService
|
||||
JournalDatabaseStatus GetStatus(string password, string? dataDirectory = null);
|
||||
JournalDatabaseHydrationResult HydrateWorkspace(string password, string? dataDirectory = null);
|
||||
}
|
||||
|
||||
public sealed record JournalDatabaseStatus(
|
||||
string DatabasePath,
|
||||
int KeyLengthBytes,
|
||||
int Iterations,
|
||||
string KeyDerivation,
|
||||
IReadOnlyList<string> SchemaTables,
|
||||
string SchemaBootstrapPath,
|
||||
bool RuntimeReady,
|
||||
string RuntimeMessage);
|
||||
|
||||
public sealed record JournalDatabaseHydrationResult(
|
||||
string DatabasePath,
|
||||
string SchemaBootstrapPath,
|
||||
int EntryFilesProcessed,
|
||||
bool RuntimeReady,
|
||||
string Message);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Journal.Core.Dtos;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Journal.Core.Dtos;
|
||||
using Journal.Core.Models;
|
||||
@ -8,25 +6,20 @@ namespace Journal.Core.Services;
|
||||
|
||||
public sealed class PythonSidecarAiService : IAiService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly JournalConfig _config;
|
||||
private readonly PythonSidecarClient _client;
|
||||
|
||||
public PythonSidecarAiService(JournalConfig config)
|
||||
{
|
||||
_config = config;
|
||||
if (string.IsNullOrWhiteSpace(_config.PythonAiSidecarPath))
|
||||
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}");
|
||||
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 SendAsync("health", payload: new { }, cancellationToken);
|
||||
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");
|
||||
|
||||
@ -47,14 +40,14 @@ public sealed class PythonSidecarAiService : IAiService
|
||||
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);
|
||||
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 SendAsync("summarize_all", new { entries }, cancellationToken);
|
||||
var data = await _client.SendAsync("summarize_all", new { entries }, cancellationToken);
|
||||
return data?.GetString() ?? "";
|
||||
}
|
||||
|
||||
@ -63,7 +56,7 @@ public sealed class PythonSidecarAiService : IAiService
|
||||
if (string.IsNullOrWhiteSpace(prompt))
|
||||
throw new ArgumentException("Prompt is required.", nameof(prompt));
|
||||
|
||||
var data = await SendAsync("chat", new { prompt }, cancellationToken);
|
||||
var data = await _client.SendAsync("chat", new { prompt }, cancellationToken);
|
||||
return data?.GetString() ?? "";
|
||||
}
|
||||
|
||||
@ -72,7 +65,7 @@ public sealed class PythonSidecarAiService : IAiService
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
throw new ArgumentException("Content is required.", nameof(content));
|
||||
|
||||
var data = await SendAsync("embed", new { content }, cancellationToken);
|
||||
var data = await _client.SendAsync("embed", new { content }, cancellationToken);
|
||||
if (data is null || data.Value.ValueKind == JsonValueKind.Null)
|
||||
return [];
|
||||
|
||||
@ -89,102 +82,4 @@ public sealed class PythonSidecarAiService : IAiService
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
119
Journal.Core/Services/PythonSidecarClient.cs
Normal file
119
Journal.Core/Services/PythonSidecarClient.cs
Normal file
@ -0,0 +1,119 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Journal.Core.Models;
|
||||
|
||||
namespace Journal.Core.Services;
|
||||
|
||||
public sealed class PythonSidecarClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly JournalConfig _config;
|
||||
|
||||
public PythonSidecarClient(JournalConfig config)
|
||||
{
|
||||
_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("{", 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Journal.Core.Dtos;
|
||||
using Journal.Core.Models;
|
||||
@ -7,25 +6,20 @@ namespace Journal.Core.Services;
|
||||
|
||||
public sealed class PythonSidecarSpeechService : ISpeechBridgeService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly JournalConfig _config;
|
||||
private readonly PythonSidecarClient _client;
|
||||
|
||||
public PythonSidecarSpeechService(JournalConfig config)
|
||||
{
|
||||
_config = config;
|
||||
if (string.IsNullOrWhiteSpace(_config.PythonAiSidecarPath))
|
||||
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}");
|
||||
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 SendAsync("speech.devices.list", new { }, cancellationToken);
|
||||
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.");
|
||||
|
||||
@ -60,7 +54,7 @@ public sealed class PythonSidecarSpeechService : ISpeechBridgeService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var data = await SendAsync("speech.transcribe", new
|
||||
var data = await _client.SendAsync("speech.transcribe", new
|
||||
{
|
||||
audio_base64 = request.AudioBase64,
|
||||
engine = request.Engine,
|
||||
@ -83,102 +77,4 @@ public sealed class PythonSidecarSpeechService : ISpeechBridgeService
|
||||
: null;
|
||||
return new SpeechTranscribeResultDto(text, engine, warning);
|
||||
}
|
||||
|
||||
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 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("{", 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 timeout cleanup failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -113,7 +113,9 @@ static Entry NewEntry() => new(
|
||||
new JournalDatabaseService(new JournalConfigService()),
|
||||
new JournalConfigService(),
|
||||
new DisabledAiService("none"),
|
||||
new DisabledSpeechBridgeService("none"));
|
||||
new DisabledSpeechBridgeService("none"),
|
||||
new EntryFileService(new DiskEntryFileRepository()),
|
||||
new CommandLogger());
|
||||
|
||||
static IJournalDatabaseService NewDatabaseService() => new JournalDatabaseService(new JournalConfigService());
|
||||
|
||||
|
||||
@ -2,4 +2,5 @@
|
||||
<Project Path="Journal.Api/Journal.Api.csproj" />
|
||||
<Project Path="Journal.Core/Journal.Core.csproj" />
|
||||
<Project Path="Journal.Sidecar/Journal.Sidecar.csproj" />
|
||||
<Project Path="Journal.SmokeTests/Journal.SmokeTests.csproj" />
|
||||
</Solution>
|
||||
|
||||
62
REFACTORING_SUMMARY.md
Normal file
62
REFACTORING_SUMMARY.md
Normal file
@ -0,0 +1,62 @@
|
||||
# Backend Refactoring Summary
|
||||
|
||||
## Problem
|
||||
`Entry.cs` was a 550-line god class that contained command dispatching, business logic, HTML sanitization, logging infrastructure, and 13 private payload record types. The two Python sidecar services (`PythonSidecarAiService` and `PythonSidecarSpeechService`) duplicated ~80 lines of identical process/JSON plumbing. Payload DTOs were hidden as private records inside `Entry.cs` instead of being in the `Dtos/` folder.
|
||||
|
||||
## What Changed
|
||||
|
||||
### 1. Slimmed `Entry.cs` to a thin dispatcher (~330 lines)
|
||||
Removed all business logic, HTML processing, logging implementation, and private record types. `Entry` now only parses the incoming JSON command, routes to the correct service, and returns the `{ok, data}` / `{ok: false, error}` envelope.
|
||||
|
||||
### 2. Extracted `HtmlSanitizer` (new file)
|
||||
`StripRichHtml` and `LooksLikeRichHtml` moved from `Entry.cs` to `Services/HtmlSanitizer.cs` as a static utility class.
|
||||
|
||||
### 3. Extracted `CommandLogger` (new file)
|
||||
`LogStart`, `LogSuccess`, `LogFailure`, `EmitLog`, `ShouldLog`, and `LogLevelRank` moved from `Entry.cs` to `Services/CommandLogger.cs`. Entry now receives this as a dependency.
|
||||
|
||||
### 4. Extracted `IEntryFileService` + `EntryFileService` (new files)
|
||||
`SaveEntry`, `LoadEntry`, `ListEntries`, and `ResolveTargetPath` moved out of `Entry.cs` into a proper service with an interface. This follows the same pattern as `IFragmentService` / `FragmentService`.
|
||||
|
||||
### 5. Added `IEntryFileRepository` + `DiskEntryFileRepository` (new files)
|
||||
`EntryFileService` now delegates all filesystem I/O (read, write, append, list, exists) to an `IEntryFileRepository`, keeping only business logic (HTML sanitization, parsing, merging) in the service. This mirrors the Fragment module's repository pattern (`IFragmentRepository` → `FragmentService`). An in-memory implementation can be swapped in for testing.
|
||||
|
||||
### 6. Extracted `PythonSidecarClient` (new file)
|
||||
The duplicated `SendAsync`, `LastJsonLine`, and `TryKill` methods were extracted from both `PythonSidecarAiService` and `PythonSidecarSpeechService` into a shared `Services/PythonSidecarClient.cs`. Both services now delegate to it.
|
||||
|
||||
### 7. Moved payload records to `Dtos/CommandDtos.cs` (new file)
|
||||
The 16 private payload/result records that were inside `Entry.cs` are now in `Dtos/CommandDtos.cs`. Records used through public interfaces (`EntrySavePayload`, `EntryListItem`, `EntryLoadResult`, `EntrySaveResult`) are public; the rest remain internal.
|
||||
|
||||
### 8. Moved database result records to `Dtos/DatabaseDtos.cs` (new file)
|
||||
`JournalDatabaseStatus` and `JournalDatabaseHydrationResult` moved from `IJournalDatabaseService.cs` to `Dtos/DatabaseDtos.cs` for consistency with the other DTO files.
|
||||
|
||||
## Files Created
|
||||
- `Journal.Core/Services/HtmlSanitizer.cs`
|
||||
- `Journal.Core/Services/CommandLogger.cs`
|
||||
- `Journal.Core/Services/IEntryFileService.cs`
|
||||
- `Journal.Core/Services/EntryFileService.cs`
|
||||
- `Journal.Core/Services/PythonSidecarClient.cs`
|
||||
- `Journal.Core/Repositories/IEntryFileRepository.cs`
|
||||
- `Journal.Core/Repositories/DiskEntryFileRepository.cs`
|
||||
- `Journal.Core/Dtos/CommandDtos.cs`
|
||||
- `Journal.Core/Dtos/DatabaseDtos.cs`
|
||||
|
||||
## Files Modified
|
||||
- `Journal.Core/Entry.cs` — slimmed to thin dispatcher
|
||||
- `Journal.Core/Services/PythonSidecarAiService.cs` — delegates to PythonSidecarClient
|
||||
- `Journal.Core/Services/PythonSidecarSpeechService.cs` — delegates to PythonSidecarClient
|
||||
- `Journal.Core/Services/IJournalDatabaseService.cs` — result records moved to Dtos
|
||||
- `Journal.Core/Services/JournalDatabaseService.cs` — added Dtos using
|
||||
- `Journal.Core/ServiceCollectionExtensions.cs` — registers new services and repository
|
||||
- `Journal.SmokeTests/Program.cs` — updated NewEntry() with new dependencies
|
||||
|
||||
## What Was NOT Changed
|
||||
- **Fragment module** — already clean, untouched
|
||||
- **Config module** — singleton reader, no changes needed
|
||||
- **Vault module** — already well-separated (crypto/storage), untouched
|
||||
- **AI/Speech interfaces and disabled variants** — untouched (only the sidecar implementations were refactored)
|
||||
- **Search module** — stateless query service, no repository needed
|
||||
- **All test logic** — no assertions or test behavior changed
|
||||
|
||||
## Verification
|
||||
- All 4 projects build successfully
|
||||
- 70/70 smoke tests pass (5 Python sidecar tests fail only when Python is not installed on the machine, which is pre-existing)
|
||||
Loading…
x
Reference in New Issue
Block a user