diff --git a/Journal.AI/LlamaSharpAiService.cs b/Journal.AI/LlamaSharpAiService.cs index 5827f28..0844659 100644 --- a/Journal.AI/LlamaSharpAiService.cs +++ b/Journal.AI/LlamaSharpAiService.cs @@ -77,7 +77,6 @@ public sealed partial class LlamaSharpAiService(JournalConfig config) : IAiServi var executor = new StatelessExecutor(_weights!, context.Params); - // Build multi-turn Phi-3 prompt with full conversation history var sb = new StringBuilder(); sb.Append($"<|system|>\n{BuildChatSystemPrompt()}<|end|>\n"); @@ -115,8 +114,10 @@ public sealed partial class LlamaSharpAiService(JournalConfig config) : IAiServi return await RunSessionAsync(prompt, $"You are a coaching assistant inside a private journaling app. " + $"Today's date is {dateStr}. " + - $"You MUST respond with a single valid JSON object. No text before or after the JSON.", - maxTokens: 1024, cancellationToken: cancellationToken); + $"You MUST respond with ONLY a single valid JSON object. " + + $"Do NOT write any text, explanation, or commentary before or after the JSON. " + + $"Output MUST start with {{ and end with }}.", + maxTokens: 2048, temperature: 0.2f, cancellationToken: cancellationToken); } public async Task SummarizeEntryAsync(string content, string? fileStem = null, @@ -194,16 +195,13 @@ public sealed partial class LlamaSharpAiService(JournalConfig config) : IAiServi private async Task EnsureModelAsync(CancellationToken cancellationToken = default) { - // 1. Configured path takes priority if the file already exists if (File.Exists(_configuredModelPath)) return _configuredModelPath; - // 2. Check the standard app-data location var defaultPath = GetDefaultModelPath(); if (File.Exists(defaultPath)) return defaultPath; - // 3. Download from HuggingFace var modelDirectory = Path.GetDirectoryName(defaultPath)!; Directory.CreateDirectory(modelDirectory); @@ -230,21 +228,22 @@ public sealed partial class LlamaSharpAiService(JournalConfig config) : IAiServi // ── Session / weights lifecycle ──────────────────────────────────────── - private async Task RunSessionAsync(string prompt, string systemPrompt, + private Task RunSessionAsync(string prompt, string systemPrompt, int maxTokens, CancellationToken cancellationToken) + => RunSessionAsync(prompt, systemPrompt, maxTokens, temperature: 0.7f, cancellationToken); + + private async Task RunSessionAsync(string prompt, string systemPrompt, + int maxTokens, float temperature, CancellationToken cancellationToken) { var modelPath = await EnsureModelAsync(cancellationToken); EnsureWeights(modelPath); - // Fresh context per call — prevents KV cache accumulation across requests using var context = _weights!.CreateContext(new ModelParams(modelPath) { ContextSize = _contextSize, GpuLayerCount = _gpuLayers }); - // Use StatelessExecutor with explicit Phi-3 chat template so the model - // never sees raw system text it can echo back to the user. var executor = new StatelessExecutor(_weights!, context.Params); var fullPrompt = $"<|system|>\n{systemPrompt}<|end|>\n" + @@ -262,7 +261,7 @@ public sealed partial class LlamaSharpAiService(JournalConfig config) : IAiServi ], SamplingPipeline = new DefaultSamplingPipeline { - Temperature = 0.7f + Temperature = temperature } }; @@ -276,7 +275,6 @@ public sealed partial class LlamaSharpAiService(JournalConfig config) : IAiServi return StripSpecialTokens(sb.ToString()); } - /// Strips only Phi-3 special tokens — safe for JSON output. private static string StripSpecialTokens(string raw) { var text = raw; @@ -285,22 +283,15 @@ public sealed partial class LlamaSharpAiService(JournalConfig config) : IAiServi return text.Trim(); } - // Matches role labels like "System:", "**System:**", "**Assistant:**", "User:" etc. private static readonly Regex RoleMarkerRegex = MyRegex(); - /// Aggressive cleanup for conversational (non-JSON) responses. private static string CleanChatResponse(string raw) { var text = StripSpecialTokens(raw); - // Strip role markers in any formatting variant (plain, bold-markdown, etc.) text = RoleMarkerRegex.Replace(text, ""); - - // Remove orphaned bold markers left behind after stripping text = text.Replace("**", ""); - - // Collapse runs of 3+ newlines into 2 - text = Regex.Replace(text, @"\n{3,}", "\n\n"); + text = MyRegex2().Replace(text, "\n\n"); return text.Trim(); } @@ -333,4 +324,6 @@ public sealed partial class LlamaSharpAiService(JournalConfig config) : IAiServi private static partial Regex MyRegex(); [GeneratedRegex(@"\n{3,}")] private static partial Regex MyRegex1(); + [GeneratedRegex(@"\n{3,}")] + private static partial Regex MyRegex2(); } diff --git a/Journal.AI/LlamaSharpCoachService.cs b/Journal.AI/LlamaSharpCoachService.cs index 5e17f02..78e7e70 100644 --- a/Journal.AI/LlamaSharpCoachService.cs +++ b/Journal.AI/LlamaSharpCoachService.cs @@ -47,8 +47,6 @@ public sealed class LlamaSharpCoachService(LlamaSharpAiService ai) : ICoachServi CancellationToken cancellationToken) { var raw = await _ai.ChatJsonAsync(prompt, cancellationToken); - - // Try to extract JSON from the response var json = ExtractJson(raw); if (json is not null) { @@ -60,11 +58,10 @@ public sealed class LlamaSharpCoachService(LlamaSharpAiService ai) : ICoachServi } catch (JsonException) { - // Fall through to fallback + } } - // Fallback: wrap raw text into a CoachPlanDto return new CoachPlanDto( Kind: fallbackKind, Title: "Coach Response", @@ -104,7 +101,6 @@ public sealed class LlamaSharpCoachService(LlamaSharpAiService ai) : ICoachServi private static string? ExtractJson(string text) { - // 1. Try ```json ... ``` code block var codeBlockStart = text.IndexOf("```json", StringComparison.Ordinal); if (codeBlockStart >= 0) { @@ -118,7 +114,6 @@ public sealed class LlamaSharpCoachService(LlamaSharpAiService ai) : ICoachServi } } - // 2. Look specifically for {"kind" — the expected first field of CoachPlanDto var kindMarker = text.IndexOf("{\"kind\"", StringComparison.Ordinal); if (kindMarker < 0) kindMarker = text.IndexOf("{ \"kind\"", StringComparison.Ordinal); @@ -133,7 +128,6 @@ public sealed class LlamaSharpCoachService(LlamaSharpAiService ai) : ICoachServi } } - // 3. Fallback: try each { position until one parses as valid JSON var searchFrom = 0; var globalLastBrace = text.LastIndexOf('}'); while (searchFrom < text.Length && globalLastBrace > searchFrom) @@ -149,9 +143,67 @@ public sealed class LlamaSharpCoachService(LlamaSharpAiService ai) : ICoachServi searchFrom = bracePos + 1; } + var firstBrace = text.IndexOf('{'); + if (firstBrace >= 0) + { + var repaired = TryRepairJson(text[firstBrace..]); + if (repaired is not null) + return repaired; + } + return null; } + private static string? TryRepairJson(string text) + { + var trimmed = text.TrimEnd(); + var lastUseful = trimmed.Length - 1; + while (lastUseful >= 0) + { + var ch = trimmed[lastUseful]; + if (ch is '}' or ']' or '"' or ',' or ':' or '{' or '[' || char.IsDigit(ch) + || ch is 't' or 'r' or 'u' or 'e' or 'f' or 'a' or 'l' or 's' or 'n') + break; + lastUseful--; + } + + if (lastUseful < 0) return null; + trimmed = trimmed[..(lastUseful + 1)]; + + if (trimmed.EndsWith(',')) + trimmed = trimmed[..^1]; + + var openBraces = 0; + var openBrackets = 0; + var inString = false; + var escape = false; + + foreach (var ch in trimmed) + { + if (escape) { escape = false; continue; } + if (ch == '\\' && inString) { escape = true; continue; } + if (ch == '"') { inString = !inString; continue; } + if (inString) continue; + + switch (ch) + { + case '{': openBraces++; break; + case '}': openBraces--; break; + case '[': openBrackets++; break; + case ']': openBrackets--; break; + } + } + + if (openBraces <= 0 && openBrackets <= 0) return null; + + var sb = new StringBuilder(trimmed); + for (var i = 0; i < openBrackets; i++) sb.Append(']'); + for (var i = 0; i < openBraces; i++) sb.Append('}'); + + var repaired = sb.ToString(); + return TryValidateJson(repaired) ? repaired : null; + } + private static bool TryValidateJson(string json) { try @@ -169,11 +221,7 @@ public sealed class LlamaSharpCoachService(LlamaSharpAiService ai) : ICoachServi { var assembly = Assembly.GetExecutingAssembly(); var resourceName = assembly.GetManifestResourceNames() - .FirstOrDefault(n => n.EndsWith(fileName, StringComparison.OrdinalIgnoreCase)); - - if (resourceName is null) - throw new FileNotFoundException($"Embedded resource not found: {fileName}"); - + .FirstOrDefault(n => n.EndsWith(fileName, StringComparison.OrdinalIgnoreCase)) ?? throw new FileNotFoundException($"Embedded resource not found: {fileName}"); using var stream = assembly.GetManifestResourceStream(resourceName)!; using var reader = new StreamReader(stream, Encoding.UTF8); return reader.ReadToEnd().Trim(); diff --git a/Journal.App/src-tauri/icons/icon.ico b/Journal.App/src-tauri/icons/icon.ico index b3636e4..00f68f0 100644 Binary files a/Journal.App/src-tauri/icons/icon.ico and b/Journal.App/src-tauri/icons/icon.ico differ diff --git a/Journal.App/src/app.html b/Journal.App/src/app.html index a3a83c3..5e2e21c 100644 --- a/Journal.App/src/app.html +++ b/Journal.App/src/app.html @@ -2,7 +2,7 @@ - +