fix: improve coach JSON reliability, add app icons

- Increase ChatJsonAsync max tokens 1024 → 2048 to prevent truncation
- Lower JSON temperature 0.7 → 0.2 for more deterministic output
- Add TryRepairJson fallback to close incomplete JSON from model
- Strengthen JSON system prompt to reduce narrative drift
- Generate icon.svg, icon.ico from source icon.png
- Update Navbar sidebar logo to use icon.png
- Update app.html favicon to use icon.ico
- Copy icon.ico to Tauri icons directory

Co-Authored-By: Oz <oz-agent@warp.dev>
This commit is contained in:
Jacob Schmidt 2026-03-01 17:23:34 -06:00
parent 53204ec59e
commit c0f7c16898
8 changed files with 80 additions and 34 deletions

View File

@ -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<string> SummarizeEntryAsync(string content, string? fileStem = null,
@ -194,16 +195,13 @@ public sealed partial class LlamaSharpAiService(JournalConfig config) : IAiServi
private async Task<string> 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<string> RunSessionAsync(string prompt, string systemPrompt,
private Task<string> RunSessionAsync(string prompt, string systemPrompt,
int maxTokens, CancellationToken cancellationToken)
=> RunSessionAsync(prompt, systemPrompt, maxTokens, temperature: 0.7f, cancellationToken);
private async Task<string> 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());
}
/// <summary>Strips only Phi-3 special tokens — safe for JSON output.</summary>
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();
/// <summary>Aggressive cleanup for conversational (non-JSON) responses.</summary>
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();
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 279 KiB

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" href="%sveltekit.assets%/icon.ico" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"

View File

@ -25,7 +25,7 @@
<aside class="navbar" aria-label="Primary navigation">
<div class="navbar-header">
<img src="svelte.svg" alt="Journal logo" />
<img src="icon.png" alt="Journal logo" style="height: 48px; width: 48px;" />
</div>
<nav class="nav-groups" aria-label="Journal sections">

BIN
Journal.App/static/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

BIN
Journal.App/static/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.0 MiB