journal/Journal.AI/LlamaSharpCoachService.cs
Jacob Schmidt 192e6e3891 feat: add AI coaching, conversation persistence, and LLamaSharp integration
- Add Journal.AI project with LLamaSharp-based AI service (Phi-3 model)
- Implement coach sessions (daily check-in, evening review, weekly review)
- Add conversation CRUD with SQLCipher persistence
- AI chat with full conversation history for context-aware replies
- Frontend: CoachPanel, AI stores, conversation stores, side panel UI
- Conversation list with create, rename, and delete support
- Fix Phi-3 output quality (system prompt leaking, token cleanup, JSON filtering)
- Fix CREATEDRAFT kind override in coach sessions

Co-Authored-By: Oz <oz-agent@warp.dev>
2026-03-01 16:07:59 -06:00

182 lines
6.5 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);
// Try to extract JSON from the response
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)
{
// Fall through to fallback
}
}
// Fallback: wrap raw text into a CoachPlanDto
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)
{
// 1. Try ```json ... ``` code block
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;
}
}
// 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);
if (kindMarker >= 0)
{
var lastBrace = text.LastIndexOf('}');
if (lastBrace > kindMarker)
{
var candidate = text[kindMarker..(lastBrace + 1)];
if (TryValidateJson(candidate))
return candidate;
}
}
// 3. Fallback: try each { position until one parses as valid JSON
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;
}
return 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));
if (resourceName is null)
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();
}
}