feat: add structured skills parsing with categories and levels

Add AI tracker awareness for skills system with proper level and category support.

Changes:
- Add extractSkills() parser function to extract structured skills data
  - Parses category-based format: "CategoryName:\n- SkillName (Lv X)"
  - Falls back to legacy string format for backward compatibility
  - Returns structured data: { version: 1, categories: {}, uncategorized: [] }

- Update prompt instructions to request structured skills format
  - AI now generates: "Skills:\nCombat:\n- Swordsmanship (Lv 5)"
  - Supports multiple categories (Combat, Magic, Social, Crafting, etc.)
  - Includes Uncategorized section for skills without clear category

- Add buildSkillsSummary() utility function
  - Converts structured skills data back to formatted text
  - Ready for future feature: syncing manual skill edits to AI context

Parser integration:
- parseUserStats() now uses extractSkills() to parse Skills section
- Stores structured data in extensionSettings.userStats.skills
- Widget reads structured data for display and level-up/down functionality

AI workflow:
1. AI generates skills in structured format (via prompt instructions)
2. Parser extracts to structured data (via extractSkills)
3. Widget displays with level controls (already implemented)
4. Raw text flows through committedTrackerData to next generation

Note: Manual skill edits (level-up/down in widget) are not yet synced back
to AI context. This requires additional work to regenerate the raw text
when skills are manually modified. buildSkillsSummary is ready for this.

Refs: Skills widget implementation (previous session)
This commit is contained in:
Lucas 'Paperboy' Rose-Winters
2025-11-06 22:06:22 +11:00
parent 0a5bad6b1c
commit 0f96c62c62
2 changed files with 142 additions and 7 deletions
+88 -4
View File
@@ -133,6 +133,88 @@ function debugLog(message, data = null) {
} }
} }
/**
* Extract structured skills data from stats text
* Parses format:
* Skills:
* CategoryName:
* - SkillName (Lv X)
* - SkillName (Lv X)
* Uncategorized:
* - SkillName (Lv X)
*
* @param {string} statsText - Stats section text containing skills
* @returns {Object|null} Structured skills data or null if not found
*/
function extractSkills(statsText) {
if (!statsText) return null;
// Find the Skills section
const skillsMatch = statsText.match(/Skills:([\s\S]*?)(?=\n\n|On Person:|Stored|Assets:|Main Quest|Optional Quest|$)/i);
if (!skillsMatch) {
// Fallback: try simple format "Skills: skill1, skill2"
const simpleMatch = statsText.match(/Skills:\s*(.+)/i);
if (simpleMatch) {
const skillsText = simpleMatch[1].trim();
if (skillsText && skillsText !== 'None') {
// Return as string for backward compatibility
return skillsText;
}
}
return null;
}
const skillsSection = skillsMatch[1];
const skillsData = {
version: 1,
categories: {},
uncategorized: []
};
// Split into lines and process
const lines = skillsSection.split('\n').map(line => line.trim()).filter(line => line);
let currentCategory = null;
for (const line of lines) {
// Check if this is a category header (ends with colon, no dash)
if (line.endsWith(':') && !line.startsWith('-')) {
currentCategory = line.slice(0, -1).trim();
if (currentCategory !== 'Uncategorized' && !skillsData.categories[currentCategory]) {
skillsData.categories[currentCategory] = [];
}
continue;
}
// Check if this is a skill line (starts with -, has level info)
const skillMatch = line.match(/^-\s*(.+?)\s*\(Lv\s*(\d+)\)/i);
if (skillMatch) {
const skillName = skillMatch[1].trim();
const level = parseInt(skillMatch[2], 10) || 1;
const skill = {
name: skillName,
level: level,
xp: 0,
maxXP: 100
};
if (currentCategory === 'Uncategorized' || currentCategory === null) {
skillsData.uncategorized.push(skill);
} else if (currentCategory && skillsData.categories[currentCategory]) {
skillsData.categories[currentCategory].push(skill);
}
}
}
// Return null if no skills were found
if (Object.keys(skillsData.categories).length === 0 && skillsData.uncategorized.length === 0) {
return null;
}
return skillsData;
}
/** /**
* Parses the model response to extract the different data sections. * Parses the model response to extract the different data sections.
* Extracts tracker data from markdown code blocks in the AI response. * Extracts tracker data from markdown code blocks in the AI response.
@@ -351,10 +433,12 @@ export function parseUserStats(statsText) {
// Parse skills section if enabled // Parse skills section if enabled
const skillsConfig = trackerConfig?.userStats?.skillsSection; const skillsConfig = trackerConfig?.userStats?.skillsSection;
if (skillsConfig?.enabled) { if (skillsConfig?.enabled) {
const skillsMatch = statsText.match(/Skills:\s*(.+)/i); const skillsData = extractSkills(statsText);
if (skillsMatch) { if (skillsData) {
extensionSettings.userStats.skills = skillsMatch[1].trim(); extensionSettings.userStats.skills = skillsData;
debugLog('[RPG Parser] Skills extracted:', skillsMatch[1].trim()); debugLog('[RPG Parser] Skills extracted:', skillsData);
} else {
debugLog('[RPG Parser] Skills extraction failed or none found');
} }
} }
+54 -3
View File
@@ -10,6 +10,53 @@ import { extensionSettings, committedTrackerData, FEATURE_FLAGS } from '../../co
// Type imports // Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */ /** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
/**
* Builds a formatted skills summary for AI context injection.
* Converts structured skills data to multi-line plaintext format organized by category.
*
* @param {Object|string} skills - Current skills (structured or legacy string)
* @returns {string} Formatted skills summary for prompt injection
* @example
* // Structured input: { version: 1, categories: { Combat: [{name: 'Swordsmanship', level: 5}] }, uncategorized: [] }
* // Returns: "Skills:\nCombat:\n- Swordsmanship (Lv 5)"
*/
export function buildSkillsSummary(skills) {
// Handle legacy string format
if (typeof skills === 'string') {
return `Skills: ${skills}`;
}
// Handle structured format
if (skills && typeof skills === 'object' && skills.version) {
let summary = 'Skills:';
const categories = skills.categories || {};
const uncategorized = skills.uncategorized || [];
// Add categorized skills
for (const [categoryName, skillsList] of Object.entries(categories)) {
if (skillsList && skillsList.length > 0) {
summary += `\n${categoryName}:`;
for (const skill of skillsList) {
summary += `\n- ${skill.name} (Lv ${skill.level})`;
}
}
}
// Add uncategorized skills
if (uncategorized.length > 0) {
summary += '\nUncategorized:';
for (const skill of uncategorized) {
summary += `\n- ${skill.name} (Lv ${skill.level})`;
}
}
return summary;
}
// Empty or invalid
return 'Skills: None';
}
/** /**
* Builds a formatted inventory summary for AI context injection. * Builds a formatted inventory summary for AI context injection.
* Converts v2 inventory structure to multi-line plaintext format. * Converts v2 inventory structure to multi-line plaintext format.
@@ -166,9 +213,13 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
// Add skills section if enabled // Add skills section if enabled
if (userStatsConfig?.skillsSection?.enabled) { if (userStatsConfig?.skillsSection?.enabled) {
const skillFields = userStatsConfig.skillsSection.customFields || []; instructions += `Skills:\n`;
const skillFieldsText = skillFields.map(f => `[${f}]`).join(', '); instructions += `[Category Name]:\n`;
instructions += `Skills: [${skillFieldsText || 'Skill1, Skill2, etc.'}]\n`; instructions += `- [Skill Name] (Lv [1-100])\n`;
instructions += `- [Another Skill] (Lv [1-100])\n`;
instructions += `Uncategorized:\n`;
instructions += `- [Uncategorized Skill] (Lv [1-100])\n`;
instructions += `(Organize skills by logical categories like Combat, Magic, Social, Crafting, etc. Include level as integer 1-100. Skills without a clear category go in Uncategorized.)\n`;
} }
// Add inventory format based on feature flag // Add inventory format based on feature flag