Monorepo with centralized build props, npm workspaces, LlamaSharp AI, SQLite/SQLCipher storage, Svelte frontend, and unified smoke tests. Co-Authored-By: Oz <oz-agent@warp.dev>
230 lines
7.9 KiB
C#
230 lines
7.9 KiB
C#
using System.Reflection;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Journal.Core.Dtos;
|
|
using Journal.Core.Services.Ai;
|
|
|
|
namespace Journal.AI;
|
|
|
|
public sealed class LlamaSharpCoachService(LlamaSharpAiService ai) : ICoachService
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
private readonly LlamaSharpAiService _ai = ai;
|
|
private readonly string _coachRules = LoadEmbeddedResource("Coach-Rules.txt");
|
|
private readonly string _dailyTemplate = LoadEmbeddedResource("Daily-Check-In.txt");
|
|
private readonly string _eveningTemplate = LoadEmbeddedResource("Evening-Review.txt");
|
|
private readonly string _weeklyTemplate = LoadEmbeddedResource("Weekly-Review.txt");
|
|
|
|
public async Task<CoachPlanDto> DailyCheckInAsync(CoachContextDto context,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var prefs = context.Preferences ?? new CoachPreferencesDto();
|
|
var prompt = InterpolateTemplate(_dailyTemplate, context, prefs);
|
|
return await RunCoachPromptAsync(prompt, "daily_checkin", cancellationToken);
|
|
}
|
|
|
|
public async Task<CoachPlanDto> EveningReviewAsync(CoachContextDto context,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var prefs = context.Preferences ?? new CoachPreferencesDto();
|
|
var prompt = InterpolateTemplate(_eveningTemplate, context, prefs);
|
|
return await RunCoachPromptAsync(prompt, "evening_review", cancellationToken);
|
|
}
|
|
|
|
public async Task<CoachPlanDto> WeeklyReviewAsync(CoachContextDto context,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var prefs = context.Preferences ?? new CoachPreferencesDto();
|
|
var prompt = InterpolateTemplate(_weeklyTemplate, context, prefs);
|
|
return await RunCoachPromptAsync(prompt, "weekly_review", cancellationToken);
|
|
}
|
|
|
|
private async Task<CoachPlanDto> RunCoachPromptAsync(string prompt, string fallbackKind,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var raw = await _ai.ChatJsonAsync(prompt, cancellationToken);
|
|
var json = ExtractJson(raw);
|
|
if (json is not null)
|
|
{
|
|
try
|
|
{
|
|
var parsed = JsonSerializer.Deserialize<CoachPlanDto>(json, JsonOptions);
|
|
if (parsed is not null)
|
|
return parsed with { Kind = fallbackKind };
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
|
|
}
|
|
}
|
|
|
|
return new CoachPlanDto(
|
|
Kind: fallbackKind,
|
|
Title: "Coach Response",
|
|
Summary: raw,
|
|
Questions: [],
|
|
SuggestedNextActions: [],
|
|
SuggestedTags: [],
|
|
Evidence: []);
|
|
}
|
|
|
|
private string InterpolateTemplate(string template, CoachContextDto context, CoachPreferencesDto prefs)
|
|
{
|
|
var contextJson = JsonSerializer.Serialize(new
|
|
{
|
|
recentEntries = context.RecentEntries ?? [],
|
|
recentFragments = context.RecentFragments ?? []
|
|
});
|
|
|
|
var preferencesJson = JsonSerializer.Serialize(new
|
|
{
|
|
prefs.MaxQuestions,
|
|
prefs.MaxNextActions
|
|
});
|
|
|
|
var result = template
|
|
.Replace("{{CoachRules}}", _coachRules)
|
|
.Replace("{{dateLocal}}", context.DateLocal)
|
|
.Replace("{{weekStartLocal}}", context.WeekStartLocal ?? "")
|
|
.Replace("{{weekEndLocal}}", context.WeekEndLocal ?? "")
|
|
.Replace("{{contextJson}}", contextJson)
|
|
.Replace("{{preferencesJson}}", preferencesJson)
|
|
.Replace("{{maxQuestions}}", prefs.MaxQuestions.ToString())
|
|
.Replace("{{maxNextActions}}", prefs.MaxNextActions.ToString());
|
|
|
|
return result;
|
|
}
|
|
|
|
private static string? ExtractJson(string text)
|
|
{
|
|
var codeBlockStart = text.IndexOf("```json", StringComparison.Ordinal);
|
|
if (codeBlockStart >= 0)
|
|
{
|
|
var jsonStart = text.IndexOf('{', codeBlockStart);
|
|
var codeBlockEnd = text.IndexOf("```", codeBlockStart + 7, StringComparison.Ordinal);
|
|
if (jsonStart >= 0 && codeBlockEnd > jsonStart)
|
|
{
|
|
var candidate = text[jsonStart..codeBlockEnd].Trim();
|
|
if (TryValidateJson(candidate))
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
var kindMarker = text.IndexOf("{\"kind\"", StringComparison.Ordinal);
|
|
if (kindMarker < 0)
|
|
kindMarker = text.IndexOf("{ \"kind\"", StringComparison.Ordinal);
|
|
if (kindMarker >= 0)
|
|
{
|
|
var lastBrace = text.LastIndexOf('}');
|
|
if (lastBrace > kindMarker)
|
|
{
|
|
var candidate = text[kindMarker..(lastBrace + 1)];
|
|
if (TryValidateJson(candidate))
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
var searchFrom = 0;
|
|
var globalLastBrace = text.LastIndexOf('}');
|
|
while (searchFrom < text.Length && globalLastBrace > searchFrom)
|
|
{
|
|
var bracePos = text.IndexOf('{', searchFrom);
|
|
if (bracePos < 0 || bracePos >= globalLastBrace)
|
|
break;
|
|
|
|
var candidate = text[bracePos..(globalLastBrace + 1)];
|
|
if (TryValidateJson(candidate))
|
|
return candidate;
|
|
|
|
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
|
|
{
|
|
using var doc = JsonDocument.Parse(json);
|
|
return doc.RootElement.ValueKind == JsonValueKind.Object;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static string LoadEmbeddedResource(string fileName)
|
|
{
|
|
var assembly = Assembly.GetExecutingAssembly();
|
|
var resourceName = assembly.GetManifestResourceNames()
|
|
.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();
|
|
}
|
|
}
|