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