Add mobile app support and opening training feature

This commit implements iOS/Android mobile app support using Capacitor
and adds a comprehensive opening training feature with LLM-powered
explanations.

## Mobile App Infrastructure

- Add Capacitor configuration for iOS/Android builds
- Create mobile build script that excludes API routes
- Update Next.js config for conditional static export
- Add layout components with generateStaticParams for static builds
- Generate 500+ static pages for offline mobile use

## Chess Engine Abstraction

- Create ChessEngine interface for pluggable implementations
- Add LocalEngine (GPL - uses stockfish.js in browser)
- Add RemoteEngine (proprietary - calls API server)
- Factory pattern selects engine based on environment
- Enables GPL compliance for web, proprietary for mobile

## Opening Training Feature

- Interactive opening repertoire training
- Move validation with engine-backed feedback
- LLM explanations using Gemini API
- Wikipedia integration for opening context
- Opening family grouping (e4, d4, c4, etc.)
- Session state management
- Real-time move feedback with evaluation

Components:
- OpeningSelector: Browse and select openings by family
- OpeningTrainer: Main training interface with chessboard
- MoveFeedback: Display move quality and LLM explanations
- WikipediaSummary: Show opening history and context
- ErrorBoundary: Graceful error handling

Services:
- openingLoader: Load and filter opening database
- engineService: Engine evaluation wrapper
- moveValidator: Validate moves against repertoire
- feedbackGenerator: Generate contextual feedback
- wikipediaService: Fetch and cache Wikipedia data
- sessionManager: Track training session state

## Wikipedia Integration

- Automatic Wikipedia article fetching for openings
- Client-side and server-side caching
- Sanitized summaries with proper formatting
- Link opening database to Wikipedia slugs
- API endpoints for on-demand fetching

## Docker Improvements

- Add entrypoint script for automatic data setup
- Fetch Wikipedia data on first container startup
- Generate opening move index automatically
- Remove generated data from git (public/openings/*.json, public/wikipedia/*.json)
- Add READMEs explaining data requirements
- Update .gitignore for generated files

## Dual Licensing Strategy

- Add LICENSING.md explaining dual licensing approach
- GPL-3.0 for web builds (includes Stockfish)
- Proprietary option for mobile builds (no GPL code)
- Single codebase, multiple licensing models
- Legal compliance documented

## API Endpoints

- POST /api/v1/llm/opening-explanation - Get LLM move explanations
- GET /api/v1/wikipedia/summary - Fetch Wikipedia summaries

## Type Updates

- Add openingTraining types
- Update Tutor component to use ChessEngine interface
- Add Gemini error handling types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Stefan
2025-12-07 18:40:58 +01:00
parent 52dd32eb15
commit 002ed92bea
57 changed files with 7015 additions and 234280 deletions
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env tsx
/**
* Add Wikipedia slugs to opening database
*
* This script updates the opening JSON files to include wikipediaSlug field
* based on the cached Wikipedia articles we've already downloaded.
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
interface OpeningMetadata {
name: string;
eco: string;
moves: string;
isEcoRoot?: boolean;
wikipediaSlug?: string;
}
/**
* Extract family name from opening name
*/
function extractFamilyName(openingName: string): string {
const separators = [':', ',', '', '—', ' - '];
for (const sep of separators) {
if (openingName.includes(sep)) {
return openingName.split(sep)[0].trim();
}
}
return openingName;
}
/**
* Convert family name to slug
*/
function familyNameToSlug(familyName: string): string {
return familyName.toLowerCase().replace(/[^a-z0-9]+/g, '-');
}
/**
* Load Wikipedia cache index to see what we have
*/
function getAvailableWikipediaSlugs(): Set<string> {
const wikiDir = path.join(__dirname, '..', 'public', 'wikipedia');
const slugs = new Set<string>();
if (!fs.existsSync(wikiDir)) {
return slugs;
}
const files = fs.readdirSync(wikiDir);
for (const file of files) {
if (file.endsWith('.json') && file !== 'index.json') {
const slug = file.replace('.json', '');
slugs.add(slug);
}
}
return slugs;
}
/**
* Update opening database files with Wikipedia slugs
*/
function updateOpeningDatabases() {
const availableSlugs = getAvailableWikipediaSlugs();
console.log(`\n📚 Found ${availableSlugs.size} Wikipedia cache files\n`);
const ecoFiles = ['ecoA', 'ecoB', 'ecoC', 'ecoD', 'ecoE'];
let totalUpdated = 0;
let totalSkipped = 0;
for (const ecoFile of ecoFiles) {
const filePath = path.join(__dirname, '..', 'public', 'openings', `${ecoFile}.json`);
console.log(`\n📖 Processing ${ecoFile}.json...`);
if (!fs.existsSync(filePath)) {
console.log(` ⚠️ File not found, skipping`);
continue;
}
const data: Record<string, OpeningMetadata> = JSON.parse(
fs.readFileSync(filePath, 'utf-8')
);
let updatedCount = 0;
let skippedCount = 0;
// Update each opening
for (const [fen, opening] of Object.entries(data)) {
const familyName = extractFamilyName(opening.name);
const slug = familyNameToSlug(familyName);
if (availableSlugs.has(slug)) {
opening.wikipediaSlug = slug;
updatedCount++;
} else {
skippedCount++;
}
}
// Write updated file
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
console.log(` ✓ Updated ${updatedCount} openings`);
console.log(` ⚠️ Skipped ${skippedCount} (no Wikipedia cache)`);
totalUpdated += updatedCount;
totalSkipped += skippedCount;
}
console.log('\n' + '='.repeat(50));
console.log('✨ Wikipedia Slug Addition Complete!\n');
console.log(`✓ Total updated: ${totalUpdated}`);
console.log(`⚠️ Total skipped: ${totalSkipped}`);
console.log('='.repeat(50) + '\n');
}
// Run the script
console.log('🔗 Adding Wikipedia Slugs to Opening Database\n');
updateOpeningDatabases();
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# Mobile build script
# Temporarily moves API folder outside src/, builds static export, then restores it
set -e
echo "🔧 Preparing mobile build..."
# Clean previous build (suppress errors for non-empty directories)
echo "🧹 Cleaning previous build..."
rm -rf .next out 2>/dev/null || true
# Backup API folder to temp location OUTSIDE src/
if [ -d "src/app/api" ]; then
echo "📦 Temporarily moving API routes outside src/..."
mv src/app/api .api_temp_mobile_build
fi
# Build with mobile configuration
echo "🏗️ Building static export for mobile..."
BUILD_TARGET=mobile NEXT_PUBLIC_USE_REMOTE_ENGINE=true next build
# Restore API folder
if [ -d ".api_temp_mobile_build" ]; then
echo "📦 Restoring API routes..."
mv .api_temp_mobile_build src/app/api
fi
echo "✅ Mobile build complete! Output in ./out"
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
set -e
echo "🔧 Chess Tutor Docker Entrypoint"
# Create directories if they don't exist
mkdir -p public/openings
mkdir -p public/wikipedia
# Check if Wikipedia cache needs to be populated
if [ ! -f "public/wikipedia/.initialized" ] || [ -z "$(ls -A public/wikipedia/*.json 2>/dev/null)" ]; then
echo "📚 Fetching Wikipedia opening data..."
npm run cache:wikipedia || echo "⚠️ Warning: Wikipedia fetch failed, continuing..."
echo "🔗 Updating Wikipedia slugs in opening database..."
npm run update:wikipedia-slugs || echo "⚠️ Warning: Wikipedia slug update failed, continuing..."
# Mark as initialized
touch public/wikipedia/.initialized
echo "✅ Wikipedia data initialized"
else
echo "✅ Wikipedia cache already populated, skipping..."
fi
# Check if opening database exists
if [ ! -f "public/openings/ecoA.json" ]; then
echo "⚠️ Warning: Opening database not found in public/openings/"
echo " Please ensure opening database files (ecoA-E.json, moveIndex.json) are available"
echo " The application will continue but opening training may not work properly"
fi
echo "🚀 Starting Chess Tutor application..."
# Execute the main command (node server.js)
exec "$@"
+447
View File
@@ -0,0 +1,447 @@
#!/usr/bin/env tsx
/**
* Wikipedia Opening Cache Builder
*
* Fetches full Wikipedia articles for chess opening families and caches them locally.
* This eliminates runtime API calls and makes Wikipedia content available offline.
*
* Wikipedia content is licensed under CC BY-SA 3.0
* https://creativecommons.org/licenses/by-sa/3.0/
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
interface WikipediaArticle {
openingFamily: string;
title: string;
url: string;
sections: {
title: string;
text: string;
}[];
lastModified: string;
license: string;
licenseUrl: string;
fetchedAt: number;
}
/**
* Extract opening family names from the opening database
*/
function extractOpeningFamilies(): string[] {
const ecoFiles = ['ecoA', 'ecoB', 'ecoC', 'ecoD', 'ecoE'];
const families = new Set<string>();
for (const ecoFile of ecoFiles) {
const filePath = path.join(__dirname, '..', 'public', 'openings', `${ecoFile}.json`);
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
// Extract family names from each opening
for (const opening of Object.values(data) as any[]) {
if (opening.name && opening.isEcoRoot === true) {
const familyName = extractFamilyName(opening.name);
if (familyName) {
families.add(familyName);
}
}
}
}
return Array.from(families).sort();
}
/**
* Extract family name from opening name
*/
function extractFamilyName(openingName: string): string {
const separators = [':', ',', '', '—', ' - '];
for (const sep of separators) {
if (openingName.includes(sep)) {
return openingName.split(sep)[0].trim();
}
}
return openingName;
}
/**
* Manual overrides for problematic opening names
* Maps opening family name -> exact Wikipedia article title
*/
const WIKIPEDIA_OVERRIDES: Record<string, string> = {
'French Defense': 'French Defence',
'French': 'French Defence',
'English Opening': 'English Opening',
'English': 'English Opening',
'Dutch Defense': 'Dutch Defence',
'Dutch': 'Dutch Defence',
'Spanish Game': 'Ruy Lopez',
'Italian Game': 'Italian Game',
'Scandinavian Defense': 'Scandinavian Defense',
'Pirc Defense': 'Pirc Defence',
'Modern Defense': 'Modern Defense (chess)',
};
/**
* Search Wikipedia for the best matching article
*/
async function searchWikipedia(openingFamily: string): Promise<string | null> {
// Check manual overrides first
if (WIKIPEDIA_OVERRIDES[openingFamily]) {
console.log(` Using manual override: "${WIKIPEDIA_OVERRIDES[openingFamily]}"`);
return WIKIPEDIA_OVERRIDES[openingFamily];
}
// Try two search strategies:
// 1. Search with "chess opening" appended (more specific)
// 2. Search with original name (fallback)
const searchQueries = [
`${openingFamily} chess opening`,
openingFamily,
];
for (const query of searchQueries) {
const searchUrl = new URL('https://en.wikipedia.org/w/api.php');
searchUrl.searchParams.set('action', 'opensearch');
searchUrl.searchParams.set('search', query);
searchUrl.searchParams.set('limit', '10'); // Increased from 5 to get more options
searchUrl.searchParams.set('namespace', '0');
searchUrl.searchParams.set('format', 'json');
console.log(` Searching Wikipedia for: "${query}"`);
const response = await fetch(searchUrl.toString(), {
headers: {
'User-Agent': 'ChessTutorApp/1.0 (Educational chess training app; cache builder)',
},
});
if (!response.ok) {
console.error(` ❌ Search failed: ${response.status}`);
continue;
}
const data = await response.json();
const titles = data[1] as string[];
const descriptions = data[2] as string[];
if (!titles || titles.length === 0) {
console.log(` ⚠️ No results found for this query`);
continue;
}
// Score each result based on chess relevance
const scoredResults = titles.map((title, i) => {
const description = descriptions[i] || '';
const lowerTitle = title.toLowerCase();
const lowerDesc = description.toLowerCase();
let score = 0;
// Skip disambiguation pages (they're not what we want)
if (lowerTitle.includes('(disambiguation)') || lowerDesc.includes('may refer to')) {
return { title, description, score: -1000 };
}
// Strong chess indicators
if (lowerDesc.includes('chess opening')) score += 100;
if (lowerTitle.includes('chess')) score += 50;
if (lowerDesc.includes('chess')) score += 30;
if (lowerDesc.includes('opening')) score += 20;
// Additional chess terms
if (lowerDesc.includes('variation') || lowerDesc.includes('defense') || lowerDesc.includes('defence')) score += 10;
if (lowerDesc.includes('game') && lowerDesc.includes('chess')) score += 15;
// Prefer exact or close matches to opening name
if (lowerTitle.includes(openingFamily.toLowerCase())) score += 40;
// Penalize generic terms that suggest it's not the chess opening
if (lowerDesc.includes('language') || lowerDesc.includes('people') ||
lowerDesc.includes('cuisine') || lowerDesc.includes('culture')) {
score -= 50;
}
return { title, description, score };
});
// Sort by score (highest first)
scoredResults.sort((a, b) => b.score - a.score);
// Pick the best match if it has a positive score
const bestMatch = scoredResults[0];
if (bestMatch && bestMatch.score > 0) {
console.log(` ✓ Found: "${bestMatch.title}" (score: ${bestMatch.score})`);
return bestMatch.title;
}
}
console.log(` ⚠️ No suitable chess article found`);
return null;
}
/**
* Fetch full Wikipedia article content
*/
async function fetchWikipediaArticle(
openingFamily: string,
articleTitle: string
): Promise<WikipediaArticle | null> {
// Use MediaWiki API to get parsed content with sections
const apiUrl = new URL('https://en.wikipedia.org/w/api.php');
apiUrl.searchParams.set('action', 'parse');
apiUrl.searchParams.set('page', articleTitle);
apiUrl.searchParams.set('prop', 'sections|text|displaytitle|revid');
apiUrl.searchParams.set('format', 'json');
apiUrl.searchParams.set('formatversion', '2');
console.log(` Fetching full article...`);
const response = await fetch(apiUrl.toString(), {
headers: {
'User-Agent': 'ChessTutorApp/1.0 (Educational chess training app; cache builder)',
},
});
if (!response.ok) {
console.error(` ❌ Fetch failed: ${response.status}`);
return null;
}
const data = await response.json();
if (data.error) {
console.error(` ❌ API error:`, data.error);
return null;
}
const parseData = data.parse;
const fullHtml = parseData.text;
const sectionsData = parseData.sections || [];
// Extract sections from the HTML
const sections = extractSections(fullHtml, sectionsData);
// Get last modified date
const lastModified = new Date().toISOString();
const article: WikipediaArticle = {
openingFamily,
title: parseData.displaytitle || articleTitle,
url: `https://en.wikipedia.org/wiki/${encodeURIComponent(articleTitle)}`,
sections,
lastModified,
license: 'CC BY-SA 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by-sa/3.0/',
fetchedAt: Date.now(),
};
// Verify this is actually a chess article
const allText = sections.map(s => s.text).join(' ').toLowerCase();
const isChessArticle =
allText.includes('chess') ||
allText.includes('opening') ||
allText.includes('variation') ||
allText.includes('defense') ||
allText.includes('defence') ||
allText.includes('game') ||
allText.includes('move');
if (!isChessArticle) {
console.log(` ⚠️ Article doesn't appear to be about chess (verification failed)`);
return null;
}
console.log(` ✓ Fetched ${sections.length} sections (verified as chess content)`);
return article;
}
/**
* Extract clean text sections from Wikipedia HTML
*/
function extractSections(
html: string,
sectionsData: any[]
): { title: string; text: string }[] {
// Parse HTML and extract meaningful sections
// For now, we'll get the intro and first few sections
const sections: { title: string; text: string }[] = [];
// Extract intro (text before first heading)
const introMatch = html.match(/<p>([\s\S]*?)(?=<h2|$)/);
if (introMatch) {
const introText = stripHtml(introMatch[1]);
if (introText.trim().length > 50) {
sections.push({
title: 'Introduction',
text: introText,
});
}
}
// Extract sections (we'll take first 5 for brevity)
const relevantSections = sectionsData
.filter((s: any) => s.toclevel === 1) // Top-level sections only
.slice(0, 5);
for (const section of relevantSections) {
const sectionTitle = section.line;
// Skip non-relevant sections
if (
sectionTitle.toLowerCase().includes('references') ||
sectionTitle.toLowerCase().includes('external links') ||
sectionTitle.toLowerCase().includes('see also') ||
sectionTitle.toLowerCase().includes('notes')
) {
continue;
}
// Extract section content
const sectionRegex = new RegExp(
`<h2[^>]*>.*?${escapeRegex(sectionTitle)}.*?</h2>([\s\S]*?)(?=<h2|$)`,
'i'
);
const sectionMatch = html.match(sectionRegex);
if (sectionMatch) {
const sectionText = stripHtml(sectionMatch[1]);
if (sectionText.trim().length > 50) {
sections.push({
title: sectionTitle,
text: sectionText,
});
}
}
}
return sections;
}
/**
* Strip HTML tags and clean text
*/
function stripHtml(html: string): string {
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '') // Remove style tags
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '') // Remove script tags
.replace(/<sup[^>]*>[\s\S]*?<\/sup>/gi, '') // Remove citation superscripts
.replace(/<\/?[^>]+(>|$)/g, '') // Remove all other tags
.replace(/\[[0-9]+\]/g, '') // Remove citation numbers [1], [2], etc.
.replace(/&nbsp;/g, ' ') // Replace &nbsp;
.replace(/&amp;/g, '&') // Replace &amp;
.replace(/&lt;/g, '<') // Replace &lt;
.replace(/&gt;/g, '>') // Replace &gt;
.replace(/\n\s*\n/g, '\n\n') // Clean up multiple newlines
.trim();
}
/**
* Escape special regex characters
*/
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Main execution
*/
async function main() {
console.log('🌐 Wikipedia Opening Cache Builder\n');
console.log('📚 Extracting opening families from database...');
const families = extractOpeningFamilies();
console.log(`✓ Found ${families.length} unique opening families\n`);
const outputDir = path.join(__dirname, '..', 'public', 'wikipedia');
// Create output directory if it doesn't exist
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
console.log(`✓ Created directory: ${outputDir}\n`);
}
const results = {
successful: 0,
failed: 0,
skipped: 0,
};
// Process each family
for (const family of families) {
console.log(`\n📖 Processing: ${family}`);
try {
// Search for the article
const articleTitle = await searchWikipedia(family);
if (!articleTitle) {
console.log(` ⚠️ Skipping (no Wikipedia article found)`);
results.skipped++;
continue;
}
// Fetch full article
const article = await fetchWikipediaArticle(family, articleTitle);
if (!article) {
console.log(` ❌ Failed to fetch article`);
results.failed++;
continue;
}
// Save to file
const slug = family.toLowerCase().replace(/[^a-z0-9]+/g, '-');
const filename = `${slug}.json`;
const filepath = path.join(outputDir, filename);
fs.writeFileSync(filepath, JSON.stringify(article, null, 2));
console.log(` ✓ Saved to: ${filename}`);
results.successful++;
// Rate limiting - be nice to Wikipedia
await sleep(1000);
} catch (error) {
console.error(` ❌ Error:`, error);
results.failed++;
}
}
// Create index file
console.log('\n📝 Creating index file...');
const indexPath = path.join(outputDir, 'index.json');
const indexData = {
generatedAt: new Date().toISOString(),
totalFamilies: families.length,
successful: results.successful,
failed: results.failed,
skipped: results.skipped,
license: 'Wikipedia content licensed under CC BY-SA 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by-sa/3.0/',
};
fs.writeFileSync(indexPath, JSON.stringify(indexData, null, 2));
// Summary
console.log('\n' + '='.repeat(50));
console.log('✨ Wikipedia Cache Build Complete!\n');
console.log(`✓ Successful: ${results.successful}`);
console.log(`⚠️ Skipped: ${results.skipped}`);
console.log(`❌ Failed: ${results.failed}`);
console.log(`📁 Output: ${outputDir}`);
console.log('='.repeat(50) + '\n');
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Run the script
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});