changed location of llm flow for npcs

This commit is contained in:
grimsace
2026-07-02 12:20:04 -05:00
parent c3f6cc2375
commit b94b23e46f
2 changed files with 373 additions and 153 deletions
+290
View File
@@ -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<int>.Failure($"invalid spell attack bonus value for system `{normalizedSystem}`, level `{level}`, column `{column}`");
}
}
public class NpcGenerator
{
private readonly ILlmService _llmService;
private Dictionary<string, (string question, string constraints)> _prompts = new();
private string _retryPrefix = "";
public NpcGenerator(ILlmService llmService)
{
_llmService = llmService;
}
/// <summary>
/// Load all prompts from llmflow/npc.toml
/// </summary>
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");
}
/// <summary>
/// Ask LLM a question with up to 3 retry attempts and constraint validation
/// </summary>
public async Task<(bool success, string response)> AskLlmAsync(
string baseUrl,
string model,
string promptKey,
Dictionary<string, string> replacements,
List<string> 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);
}
/// <summary>
/// Generate name if not provided. Tries name_from_description_and_level, then name_from_level, then name
/// </summary>
public async Task<string> GenerateNameAsync(
string baseUrl,
string model,
string description,
int level,
List<string> imagePaths)
{
if (!string.IsNullOrWhiteSpace(description))
{
// Try generating from description + level
var replacements = new Dictionary<string, string>
{
{ "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<string, string>();
(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<string, string>
{
{ "level", level.ToString() }
};
var (levelSuccess, levelName) = await AskLlmAsync(baseUrl, model, "name_from_level", levelReplacements, imagePaths);
return (levelSuccess && !string.IsNullOrWhiteSpace(levelName)) ? levelName : "NPC";
}
/// <summary>
/// Generate description if not provided. Uses description_from_name_and_level
/// </summary>
public async Task<string> GenerateDescriptionAsync(
string baseUrl,
string model,
string name,
int level,
List<string> imagePaths)
{
var replacements = new Dictionary<string, string>
{
{ "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;
}
/// <summary>
/// Validate a response against constraints and return normalized response
/// </summary>
private (bool isValid, string normalizedResponse) ValidateResponse(string response, string constraints)
{
var allowed = constraints.Trim();
var optionsStart = allowed.IndexOf(":");
List<string> 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);
}
}
}