From b94b23e46f0e07354c73d7d39cf29ce9df360119 Mon Sep 17 00:00:00 2001 From: grimsace Date: Thu, 2 Jul 2026 12:20:04 -0500 Subject: [PATCH] changed location of llm flow for npcs --- converters/npc.cs | 290 ++++++++++++++++++++++++++++++ viewmodels/mainwindowviewmodel.cs | 236 +++++++++--------------- 2 files changed, 373 insertions(+), 153 deletions(-) diff --git a/converters/npc.cs b/converters/npc.cs index d8334bc..abfe32f 100644 --- a/converters/npc.cs +++ b/converters/npc.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Threading.Tasks; +using finder2e_foundry_converter.Services; namespace finder2e_foundry_converter.Converters { @@ -725,4 +728,291 @@ namespace finder2e_foundry_converter.Converters return Result.Failure($"invalid spell attack bonus value for system `{normalizedSystem}`, level `{level}`, column `{column}`"); } } + + public class NpcGenerator + { + private readonly ILlmService _llmService; + private Dictionary _prompts = new(); + private string _retryPrefix = ""; + + public NpcGenerator(ILlmService llmService) + { + _llmService = llmService; + } + + /// + /// Load all prompts from llmflow/npc.toml + /// + public async Task LoadPromptsAsync() + { + _prompts.Clear(); + _retryPrefix = ""; + + var cwd = Directory.GetCurrentDirectory(); + var tomlPath = Path.Combine(cwd, "llmflow", "npc.toml"); + + if (!File.Exists(tomlPath)) + { + Console.WriteLine($"Warning: TOML file not found at {tomlPath}"); + return; + } + + var lines = await File.ReadAllLinesAsync(tomlPath); + string? currentSection = null; + + foreach (var rawLine in lines) + { + var line = rawLine.Trim(); + if (string.IsNullOrEmpty(line) || line.StartsWith("#")) continue; + + // Parse section headers [section_name] + if (line.StartsWith("[") && line.EndsWith("]")) + { + currentSection = line.Substring(1, line.Length - 2).Trim(); + continue; + } + + // Parse key = "value" + if (line.Contains("=")) + { + var idx = line.IndexOf('='); + var key = line.Substring(0, idx).Trim(); + var valStr = line.Substring(idx + 1).Trim().Trim('"'); + + if (key == "retry_prefix") + { + _retryPrefix = valStr; + Console.WriteLine($"Loaded retry_prefix"); + } + else if (currentSection != null) + { + if (key == "question") + { + if (!_prompts.ContainsKey(currentSection)) + _prompts[currentSection] = (valStr, string.Empty); + else + _prompts[currentSection] = (valStr, _prompts[currentSection].constraints); + } + else if (key == "constraints") + { + if (!_prompts.ContainsKey(currentSection)) + _prompts[currentSection] = (string.Empty, valStr); + else + _prompts[currentSection] = (_prompts[currentSection].question, valStr); + } + } + } + } + + Console.WriteLine($"Loaded {_prompts.Count} prompt sections from npc.toml"); + } + + /// + /// Ask LLM a question with up to 3 retry attempts and constraint validation + /// + public async Task<(bool success, string response)> AskLlmAsync( + string baseUrl, + string model, + string promptKey, + Dictionary replacements, + List imagePaths) + { + if (!_prompts.TryGetValue(promptKey, out var prompt_tuple)) + { + Console.WriteLine($"Warning: Prompt '{promptKey}' not found in loaded prompts"); + return (false, string.Empty); + } + + var (questionTemplate, constraints) = prompt_tuple; + var prompt = questionTemplate; + foreach (var kvp in replacements) + { + prompt = prompt.Replace($"{{{kvp.Key}}}", kvp.Value); + } + + string lastResponse = string.Empty; + + for (int attempt = 1; attempt <= 3; attempt++) + { + Console.WriteLine($"\n=== Asking LLM for '{promptKey}' (attempt {attempt}/3) ==="); + Console.WriteLine($"Prompt: {prompt}"); + if (!string.IsNullOrWhiteSpace(constraints)) + Console.WriteLine($"Constraints: {constraints}"); + + var resp = await _llmService.AskAsync(baseUrl, model, prompt, imagePaths); + var trimmed = resp?.Trim() ?? string.Empty; + + Console.WriteLine($"LLM Response: '{trimmed}'"); + + // Check for error response + if (trimmed.StartsWith("Error:", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine($"-> Response is an error, will retry"); + prompt = _retryPrefix + "\nPrevious error: " + trimmed + "\n\n" + questionTemplate; + foreach (var kvp in replacements) + { + prompt = prompt.Replace($"{{{kvp.Key}}}", kvp.Value); + } + lastResponse = trimmed; + continue; + } + + // Validate against constraints if available + if (!string.IsNullOrWhiteSpace(constraints)) + { + var (isValid, normalizedResponse) = ValidateResponse(trimmed, constraints); + if (isValid) + { + Console.WriteLine($"✓ Response is valid: '{normalizedResponse}'"); + return (true, normalizedResponse); + } + else + { + Console.WriteLine($"✗ Response '{trimmed}' does not match constraints"); + if (attempt < 3) + { + prompt = _retryPrefix + $"\nPrevious response was: '{trimmed}'\n\nPlease respond with the correct format:\n{constraints}\n\n{questionTemplate}"; + foreach (var kvp in replacements) + { + prompt = prompt.Replace($"{{{kvp.Key}}}", kvp.Value); + } + } + lastResponse = trimmed; + continue; + } + } + else + { + // No constraints, accept any non-empty response + if (!string.IsNullOrWhiteSpace(trimmed)) + { + Console.WriteLine($"✓ Response accepted (no constraints)"); + return (true, trimmed); + } + else + { + Console.WriteLine($"✗ Empty response, will retry"); + if (attempt < 3) + { + prompt = _retryPrefix + "\nPrevious response was empty.\n\n" + questionTemplate; + foreach (var kvp in replacements) + { + prompt = prompt.Replace($"{{{kvp.Key}}}", kvp.Value); + } + } + lastResponse = trimmed; + } + } + } + + Console.WriteLine($"\n✗✗✗ Failed to get valid response for '{promptKey}' after 3 attempts. Last response: '{lastResponse}'"); + return (false, lastResponse); + } + + /// + /// Generate name if not provided. Tries name_from_description_and_level, then name_from_level, then name + /// + public async Task GenerateNameAsync( + string baseUrl, + string model, + string description, + int level, + List imagePaths) + { + if (!string.IsNullOrWhiteSpace(description)) + { + // Try generating from description + level + var replacements = new Dictionary + { + { "description", description }, + { "level", level.ToString() } + }; + + var (success, name) = await AskLlmAsync(baseUrl, model, "name_from_description_and_level", replacements, imagePaths); + if (success && !string.IsNullOrWhiteSpace(name)) + return name; + + // Fallback to just description + replacements = new Dictionary(); + (success, name) = await AskLlmAsync(baseUrl, model, "name", replacements, imagePaths); + if (success && !string.IsNullOrWhiteSpace(name)) + return name; + } + + // If no description, generate from level alone + var levelReplacements = new Dictionary + { + { "level", level.ToString() } + }; + + var (levelSuccess, levelName) = await AskLlmAsync(baseUrl, model, "name_from_level", levelReplacements, imagePaths); + return (levelSuccess && !string.IsNullOrWhiteSpace(levelName)) ? levelName : "NPC"; + } + + /// + /// Generate description if not provided. Uses description_from_name_and_level + /// + public async Task GenerateDescriptionAsync( + string baseUrl, + string model, + string name, + int level, + List imagePaths) + { + var replacements = new Dictionary + { + { "name", name }, + { "level", level.ToString() } + }; + + var (success, description) = await AskLlmAsync(baseUrl, model, "description_from_name_and_level", replacements, imagePaths); + return (success && !string.IsNullOrWhiteSpace(description)) ? description : string.Empty; + } + + /// + /// Validate a response against constraints and return normalized response + /// + private (bool isValid, string normalizedResponse) ValidateResponse(string response, string constraints) + { + var allowed = constraints.Trim(); + var optionsStart = allowed.IndexOf(":"); + + List options = new(); + if (optionsStart >= 0) + { + var after = allowed.Substring(optionsStart + 1); + // Split by commas and newlines + var parts = after.Split(new[] { ',', '\n' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var p in parts) + { + var t = p.Replace("or", "", StringComparison.OrdinalIgnoreCase).Trim(); + if (!string.IsNullOrEmpty(t)) + options.Add(t.Trim().Trim('.')); + } + } + + Console.WriteLine($"Parsed allowed options: {string.Join(", ", options)}"); + + if (options.Count > 0) + { + // Check exact match + if (options.Any(o => string.Equals(o, response, StringComparison.OrdinalIgnoreCase))) + { + return (true, response); + } + + // Try first word match + var firstWord = response.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty; + if (options.Any(o => string.Equals(o, firstWord, StringComparison.OrdinalIgnoreCase))) + { + return (true, firstWord); + } + + return (false, response); + } + + // No options parsed, accept any non-empty response + return (!string.IsNullOrWhiteSpace(response), response); + } + } } diff --git a/viewmodels/mainwindowviewmodel.cs b/viewmodels/mainwindowviewmodel.cs index 294085e..4accda6 100644 --- a/viewmodels/mainwindowviewmodel.cs +++ b/viewmodels/mainwindowviewmodel.cs @@ -20,6 +20,7 @@ namespace finder2e_foundry_converter.ViewModels { private readonly LmStudioService _lmStudioService = new(); private readonly OllamaService _ollamaService = new(); + private NpcGenerator? _npcGenerator; private ILlmService CurrentService => SelectedProvider == "Ollama" ? (ILlmService)_ollamaService : _lmStudioService; @@ -105,10 +106,6 @@ namespace finder2e_foundry_converter.ViewModels [ObservableProperty] private string _temporaryNpcPath = string.Empty; - // Holds the raw TOML prompts and retry prefix - private Dictionary _npcPrompts = new(); - private string _npcRetryPrefix = ""; - public MainWindowViewModel() { // Load saved preferences would go here if we had a settings service @@ -182,51 +179,57 @@ namespace finder2e_foundry_converter.ViewModels try { - // Load the prompts from llmflow/npc.toml - var cwd = Directory.GetCurrentDirectory(); - var tomlPath = Path.Combine(cwd, "llmflow", "npc.toml"); - _npcPrompts.Clear(); - _npcRetryPrefix = ""; - if (File.Exists(tomlPath)) + // Initialize NpcGenerator + if (_npcGenerator == null) { - var lines = await File.ReadAllLinesAsync(tomlPath); - string? current = null; - foreach (var raw in lines) - { - var line = raw.Trim(); - if (string.IsNullOrEmpty(line) || line.StartsWith("#")) continue; - if (line.StartsWith("[") && line.EndsWith("]")) - { - current = line.Substring(1, line.Length - 2).Trim(); - continue; - } - if (line.StartsWith("retry_prefix")) - { - var idx = line.IndexOf('='); - if (idx >= 0) _npcRetryPrefix = line.Substring(idx + 1).Trim().Trim('"'); - continue; - } - if (current != null && line.Contains("= ")) - { - var idx = line.IndexOf('='); - var key = line.Substring(0, idx).Trim(); - var val = line.Substring(idx + 1).Trim().Trim('"'); - if (key == "question") - { - if (!_npcPrompts.ContainsKey(current)) _npcPrompts[current] = (val, string.Empty); - else _npcPrompts[current] = (val, _npcPrompts[current].constraints); - } - else if (key == "constraints") - { - if (!_npcPrompts.ContainsKey(current)) _npcPrompts[current] = (string.Empty, val); - else _npcPrompts[current] = (_npcPrompts[current].question, val); - } - } - } + _npcGenerator = new NpcGenerator(CurrentService); } + // Load prompts from TOML + Console.WriteLine("Loading prompts from npc.toml..."); + await _npcGenerator.LoadPromptsAsync(); Progress = 0.05; + // Handle name/description generation + var name = Name ?? string.Empty; + var description = Description ?? string.Empty; + + if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(description)) + { + // Generate both: name first, then description + StatusMessage = "Generating NPC name..."; + name = await _npcGenerator.GenerateNameAsync( + CurrentAddress, SelectedModel!, description, SelectedLevel, + ImageItems.Select(i => i.Path).ToList()); + if (string.IsNullOrWhiteSpace(name)) name = "NPC"; + Progress = 0.15; + + StatusMessage = "Generating NPC description..."; + description = await _npcGenerator.GenerateDescriptionAsync( + CurrentAddress, SelectedModel!, name, SelectedLevel, + ImageItems.Select(i => i.Path).ToList()); + Progress = 0.25; + } + else if (string.IsNullOrWhiteSpace(name)) + { + // Generate name from description + StatusMessage = "Generating NPC name..."; + name = await _npcGenerator.GenerateNameAsync( + CurrentAddress, SelectedModel!, description, SelectedLevel, + ImageItems.Select(i => i.Path).ToList()); + if (string.IsNullOrWhiteSpace(name)) name = "NPC"; + Progress = 0.15; + } + else if (string.IsNullOrWhiteSpace(description)) + { + // Generate description from name + StatusMessage = "Generating NPC description..."; + description = await _npcGenerator.GenerateDescriptionAsync( + CurrentAddress, SelectedModel!, name, SelectedLevel, + ImageItems.Select(i => i.Path).ToList()); + Progress = 0.25; + } + // Ask LLM for tiers per-stat var statKeys = new[] { "ability_score_modifier", "perception_modifier", "skill_modifier", "ac", "saving_throw", "hp", "resistance_or_weakness", "strike_attack_bonus", "strike_damage_roll" }; var responses = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -235,121 +238,45 @@ namespace finder2e_foundry_converter.ViewModels foreach (var key in statKeys) { - string questionTemplate = _npcPrompts.ContainsKey(key) ? _npcPrompts[key].question : null; - string constraints = _npcPrompts.ContainsKey(key) ? _npcPrompts[key].constraints : string.Empty; - if (string.IsNullOrWhiteSpace(questionTemplate)) + try { - // Fallback question - questionTemplate = key switch + StatusMessage = $"Asking LLM for {key}..."; + var replacements = new Dictionary { - "ability_score_modifier" => "Based on the description, how would you rate ability scores for this character?", - "perception_modifier" => "Based on the description, how would you rate perception for this character?", - "skill_modifier" => "Based on the description, how would you rate skills for this character?", - "ac" => "Based on the description, how would you rate the armor class (AC) for this character?", - "saving_throw" => "Based on the description, how would you rate saving throws for this character?", - "hp" => "Based on the description, how would you rate hit points for this character?", - "resistance_or_weakness" => "Based on the description, does this character have notable resistances or weaknesses?", - "strike_attack_bonus" => "Based on the description, how would you rate the strike attack bonus for this character?", - "strike_damage_roll" => "Based on the description, what is the strike damage dice roll for this character?", - _ => $"Based on the description, provide a value for {key}." + { "level", SelectedLevel.ToString() }, + { "system", SelectedSystem ?? "pf2e" }, + { "name", name }, + { "description", description } }; + + var (success, response) = await _npcGenerator.AskLlmAsync( + CurrentAddress, SelectedModel!, key, replacements, + ImageItems.Select(i => i.Path).ToList()); + + if (!success) + { + StatusMessage = $"LLM failed to provide valid response for {key} after 3 attempts."; + IsGenerating = false; + return; + } + + responses[key] = response; + done++; + Progress = 0.25 + 0.65 * ((double)done / total); } - - // Replace placeholders - var prompt = questionTemplate.Replace("{level}", SelectedLevel.ToString()).Replace("{system}", SelectedSystem).Replace("{description}", Description ?? string.Empty).Replace("{name}", Name ?? string.Empty).Replace("{modifierTier}", ""); - string lastResponse = string.Empty; - bool ok = false; - string finalResp = string.Empty; - for (int attempt = 1; attempt <= 3; attempt++) + catch (Exception ex) { - StatusMessage = $"Asking LLM for {key} (attempt {attempt})..."; - var resp = await CurrentService.AskAsync(CurrentAddress, SelectedModel!, prompt, ImageItems.Select(i => i.Path).ToList()); - var trimmed = resp?.Trim() ?? string.Empty; - - // If LLM returned an explicit Error: prefix, consider that a failure - if (trimmed.StartsWith("Error:", StringComparison.OrdinalIgnoreCase)) - { - // feed back to next attempt - prompt = _npcRetryPrefix + "\nPrevious error: " + trimmed + "\n" + prompt; - lastResponse = trimmed; - continue; - } - - // Validate against constraints if available (simple check for listed words) - if (!string.IsNullOrWhiteSpace(constraints)) - { - var allowed = constraints.Trim(); - // extract words inside the constraint description (e.g., Respond with only a single word: Extreme, High, Moderate, or Low.) - var optionsStart = allowed.IndexOf(":"); - List options = new(); - if (optionsStart >= 0) - { - var after = allowed.Substring(optionsStart + 1); - // split by commas and 'or' - var parts = after.Split(new[] { ',', '\n' }, StringSplitOptions.RemoveEmptyEntries); - foreach (var p in parts) - { - var t = p.Replace("or", "", StringComparison.OrdinalIgnoreCase).Trim(); - if (!string.IsNullOrEmpty(t)) options.Add(t.Trim().Trim('.')); - } - } - - if (options.Count > 0) - { - // check if trimmed matches one of options - if (options.Any(o => string.Equals(o, trimmed, StringComparison.OrdinalIgnoreCase))) - { - ok = true; - finalResp = trimmed; - break; - } - else - { - // try extracting first word - var firstWord = trimmed.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty; - if (options.Any(o => string.Equals(o, firstWord, StringComparison.OrdinalIgnoreCase))) - { - ok = true; - finalResp = firstWord; - break; - } - } - } - else - { - // no options parsed, accept any non-empty response - if (!string.IsNullOrWhiteSpace(trimmed)) { ok = true; finalResp = trimmed; break; } - } - - // not valid: prepare retry prompt - prompt = _npcRetryPrefix + "\nPrevious response: " + trimmed + "\nPlease respond with the format required: " + constraints + "\n" + questionTemplate.Replace("{level}", SelectedLevel.ToString()).Replace("{system}", SelectedSystem); - lastResponse = trimmed; - continue; - } - else - { - if (!string.IsNullOrWhiteSpace(trimmed)) { ok = true; finalResp = trimmed; break; } - prompt = _npcRetryPrefix + "\nPrevious response: " + trimmed + "\n" + prompt; - lastResponse = trimmed; - } - } - - if (!ok) - { - StatusMessage = $"LLM failed to provide valid response for {key} after 3 attempts. Last response: {lastResponse}"; + StatusMessage = $"Error while asking for {key}: {ex.Message}"; IsGenerating = false; return; } - - responses[key] = finalResp; - done++; - Progress = 0.05 + 0.85 * ((double)done / total); } // Finished LLM prompts Progress = 0.95; // Load template JSON + var cwd = Directory.GetCurrentDirectory(); var templatePath = Path.Combine(cwd, "foundry_templates", "npc_template.json"); if (!File.Exists(templatePath)) { @@ -362,7 +289,7 @@ namespace finder2e_foundry_converter.ViewModels var node = JsonNode.Parse(templateText)!.AsObject(); // Fill basic fields - node["name"] = string.IsNullOrWhiteSpace(Name) ? "NPC" : Name; + node["name"] = string.IsNullOrWhiteSpace(name) ? "NPC" : name; node["img"] = node["img"] ?? "systems/pf2e/icons/default-icons/npc.svg"; // system defaults @@ -376,12 +303,14 @@ namespace finder2e_foundry_converter.ViewModels systemNode["details"] = detailsNode; } var details = detailsNode.AsObject(); - details["blurb"] = Description ?? string.Empty; + details["blurb"] = description ?? string.Empty; + details["publicNotes"] = description ?? string.Empty; if (!details.TryGetPropertyValue("level", out var levelNode) || levelNode is null) { details["level"] = new JsonObject(); + levelNode = details["level"]; } - details["level"].AsObject()["value"] = SelectedLevel; + levelNode!.AsObject()["value"] = SelectedLevel; string gameSystem = SelectedSystem ?? "pf2e"; @@ -445,7 +374,7 @@ namespace finder2e_foundry_converter.ViewModels var hpRes = NpcConverter.Hp(SelectedLevel, hpTier, gameSystem); attributes["hp"] = new JsonObject { ["value"] = hpRes.IsSuccess ? hpRes.Value : 10, ["temp"] = 0, ["max"] = hpRes.IsSuccess ? hpRes.Value : 10, ["details"] = string.Empty }; - // Skills: populate common PF2e skills using skillTier + // Skills var skillsNode = new JsonObject(); var skillList = new[] { "acrobatics", "arcana", "athletics", "crafting", "deception", "diplomacy", "intimidation", "lore", "medicine", "nature", "occultism", "performance", "religion", "society", "stealth", "survival", "thievery" }; foreach (var sk in skillList) @@ -455,7 +384,7 @@ namespace finder2e_foundry_converter.ViewModels } systemNode["skills"] = skillsNode; - // Items: create a basic Strike item using strike attack bonus and damage from LLM response tiers + // Items: create a basic Strike item var items = new JsonArray(); var strikeTier = GetTier("strike_attack_bonus"); var strikeAttack = NpcConverter.StrikeAttackBonus(SelectedLevel, strikeTier, gameSystem); @@ -482,15 +411,15 @@ namespace finder2e_foundry_converter.ViewModels // Finalize JSON text GeneratedNpcJson = node.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); - // Save to a temporary file while generating + // Save to a temporary file var tempDir = Path.Combine(Path.GetTempPath(), "Finder2eFoundryConverter"); if (!Directory.Exists(tempDir)) Directory.CreateDirectory(tempDir); - var safeName = string.IsNullOrWhiteSpace(Name) ? "npc" : string.Concat(Name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '_' : ch)); + var safeName = string.IsNullOrWhiteSpace(name) ? "npc" : string.Concat(name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '_' : ch)); var tempPath = Path.Combine(tempDir, $"{safeName}_{DateTime.Now:yyyyMMddHHmmss}.json"); await File.WriteAllTextAsync(tempPath, GeneratedNpcJson); TemporaryNpcPath = tempPath; - StatusMessage = $"NPC generated and saved to temporary path: {tempPath}"; + StatusMessage = $"NPC generated successfully!"; Progress = 1.0; } catch (Exception ex) @@ -503,6 +432,7 @@ namespace finder2e_foundry_converter.ViewModels } } + [RelayCommand] private void AddImagePath(string path) {