// Enhanced Natural Language Search Service with Backend Integration
import type { PokemonData } from '../types/pokemon';
const API_BASE = '/api'; // Uses Vite proxy to forward to backend
interface BackendResponse {
summary: string;
results: unknown[];
isSinglePokemon?: boolean;
includeBattleAnalysis?: boolean;
}
interface SearchIntent {
type: 'counter' | 'team' | 'move' | 'type' | 'general';
target?: string;
context?: SearchContext;
strategy?: string;
requirements?: TeamRequirements;
moveNames?: string[];
learners?: PokemonRequirements;
types?: string[];
stats?: StatRequirements;
searchTerms?: string[];
}
interface SearchContext {
tier?: string;
format?: string;
}
interface TeamRequirements {
strategy?: string;
types?: string[];
tier?: string;
}
interface PokemonRequirements {
types?: string[];
stats?: StatRequirements;
}
interface StatRequirements {
minSpeed?: number;
minAttack?: number;
minDefense?: number;
minHp?: number;
}
interface SearchResults {
target?: PokemonData;
counters?: PokemonData[];
competitiveInsights?: unknown[];
effectiveTypes?: string[];
suggestedTeams?: unknown[];
synergies?: unknown[];
metaTrends?: unknown[];
moves?: unknown[];
pokemonByMove?: Array<{ move: unknown; pokemon: unknown[] }>;
pokemon?: unknown[];
facets?: unknown;
abilities?: unknown[];
items?: unknown[];
error?: string;
}
interface FormattedResults {
intent: SearchIntent;
results: SearchResults;
suggestions: CounterSuggestion[];
summary?: string;
}
interface CounterSuggestion {
pokemon: string;
reasoning: string;
matchupScore: number;
}
export class EnhancedNaturalLanguageSearchService {
async processNaturalLanguageQuery(query: string): Promise<FormattedResults> {
try {
// Call backend API
const response = await fetch(`${API_BASE}/nlp-search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query })
});
if (!response.ok) {
throw new Error(`Search failed: ${response.statusText}`);
}
const data: BackendResponse = await response.json();
// Handle single Pokemon search with battle analysis
if (data.isSinglePokemon && data.results.length > 0) {
const pokemon = data.results[0] as PokemonData;
const intent = this.parseIntent(query);
// Generate battle analysis locally for single Pokemon
const battleAnalysis = await this.generateBattleAnalysis(pokemon);
return {
intent,
results: {
pokemon: [pokemon],
...battleAnalysis
},
suggestions: battleAnalysis.counters ?
this.generateCounterSuggestions(battleAnalysis.counters, pokemon) : [],
summary: data.summary
};
}
// Multi-word queries - use AI summary from backend
const intent = this.parseIntent(query);
return {
intent,
results: {
pokemon: data.results.filter((r: unknown) => {
const obj = r as Record<string, unknown>;
return obj.objectID && obj.types;
}),
moves: data.results.filter((r: unknown) => {
const obj = r as Record<string, unknown>;
return obj.power !== undefined;
}),
abilities: data.results.filter((r: unknown) => {
const obj = r as Record<string, unknown>;
return obj.isHidden !== undefined;
}),
items: data.results.filter((r: unknown) => {
const obj = r as Record<string, unknown>;
return obj.category && !obj.types;
})
},
suggestions: [],
summary: data.summary
};
} catch (error) {
console.error('Search error:', error);
// Fallback to local search if backend fails
return this.fallbackLocalSearch(query);
}
}
private async generateBattleAnalysis(pokemon: PokemonData): Promise<Partial<SearchResults>> {
// This replicates the battle analysis logic from the original service
// but uses the Pokemon data from the backend
const effectiveTypes = new Set<string>();
// Calculate type weaknesses
if (pokemon.typeEffectiveness?.weakTo) {
pokemon.typeEffectiveness.weakTo.forEach(type => effectiveTypes.add(type));
}
// Find potential counters based on type advantages
const counters: PokemonData[] = [];
// In a real implementation, you'd search for Pokemon with these types
// For now, return the analysis structure
return {
target: pokemon,
effectiveTypes: Array.from(effectiveTypes),
counters: counters,
competitiveInsights: []
};
}
private generateCounterSuggestions(counters: PokemonData[], target: PokemonData): CounterSuggestion[] {
return counters.slice(0, 5).map(counter => ({
pokemon: counter.name,
reasoning: this.generateCounterReasoning(counter, target),
matchupScore: this.calculateMatchupScore(counter, target)
}));
}
private generateCounterReasoning(counter: PokemonData, target: PokemonData): string {
const reasons = [];
// Type advantage
const hasTypeAdvantage = counter.types.some((type: string) =>
target.typeEffectiveness?.weakTo?.includes(type)
);
if (hasTypeAdvantage) {
reasons.push(`Has type advantage with ${counter.types.join('/')}`);
}
// Speed advantage
if (counter.stats.speed > target.stats.speed) {
reasons.push(`Outspeeds ${target.name} (${counter.stats.speed} vs ${target.stats.speed})`);
}
// Defensive advantage
const resistsTargetTypes = target.types.some((type: string) =>
counter.typeEffectiveness?.resistantTo?.includes(type)
);
if (resistsTargetTypes) {
reasons.push(`Resists ${target.name}'s STAB moves`);
}
return reasons.join('. ') || 'Strong counter option.';
}
private calculateMatchupScore(counter: PokemonData, target: PokemonData): number {
let score = 50;
// Type effectiveness
if (counter.types.some((type: string) => target.typeEffectiveness?.weakTo?.includes(type))) {
score += 25;
}
// Speed tier
if (counter.stats.speed > target.stats.speed) {
score += 15;
}
// Defensive matchup
if (target.types.every((type: string) => counter.typeEffectiveness?.resistantTo?.includes(type))) {
score += 10;
}
return Math.min(score, 95);
}
private parseIntent(query: string): SearchIntent {
const lowerQuery = query.toLowerCase();
// Counter/matchup detection
if (this.isCounterQuery(lowerQuery)) {
return {
type: 'counter',
target: this.extractPokemonName(query),
context: this.extractContext(query)
};
}
// Team building
if (this.isTeamQuery(lowerQuery)) {
return {
type: 'team',
strategy: this.extractStrategy(query),
requirements: this.extractTeamRequirements(query)
};
}
// Move search
if (this.isMoveQuery(lowerQuery)) {
return {
type: 'move',
moveNames: this.extractMoveNames(query),
learners: this.extractPokemonRequirements(query)
};
}
// Type-based search
if (this.isTypeQuery(lowerQuery)) {
return {
type: 'type',
types: this.extractTypes(query),
stats: this.extractStatRequirements(query)
};
}
// Default to general search
return {
type: 'general',
searchTerms: query.split(' ').filter(term => term.length > 2)
};
}
private isCounterQuery(query: string): boolean {
const counterKeywords = ['counter', 'beat', 'against', 'vs', 'versus', 'defeat', 'stop'];
return counterKeywords.some(keyword => query.includes(keyword));
}
private isTeamQuery(query: string): boolean {
const teamKeywords = ['team', 'build', 'squad', 'composition', 'core'];
return teamKeywords.some(keyword => query.includes(keyword));
}
private isMoveQuery(query: string): boolean {
const moveKeywords = ['move', 'learn', 'knows', 'attack', 'tm', 'tutor'];
return moveKeywords.some(keyword => query.includes(keyword));
}
private isTypeQuery(query: string): boolean {
const types = ['normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting',
'poison', 'ground', 'flying', 'psychic', 'bug', 'rock', 'ghost',
'dragon', 'dark', 'steel', 'fairy'];
return types.some(type => query.includes(type));
}
private extractPokemonName(query: string): string {
const words = query.split(' ');
return words.find(word => word.length > 3 && word[0] === word[0].toUpperCase()) || '';
}
private extractTypes(query: string): string[] {
const types = ['normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting',
'poison', 'ground', 'flying', 'psychic', 'bug', 'rock', 'ghost',
'dragon', 'dark', 'steel', 'fairy'];
const found: string[] = [];
const lowerQuery = query.toLowerCase();
types.forEach(type => {
if (lowerQuery.includes(type)) {
found.push(type.charAt(0).toUpperCase() + type.slice(1));
}
});
return found;
}
private extractStatRequirements(query: string): StatRequirements {
const requirements: StatRequirements = {};
if (query.includes('fast') || query.includes('speed')) {
requirements.minSpeed = 90;
}
if (query.includes('bulky') || query.includes('tank')) {
requirements.minHp = 80;
requirements.minDefense = 80;
}
if (query.includes('offensive') || query.includes('attacker')) {
requirements.minAttack = 100;
}
return requirements;
}
private extractStrategy(query: string): string {
const strategies = ['rain', 'sun', 'sand', 'hail', 'trick room', 'hyper offense',
'stall', 'balance', 'bulky offense'];
const lowerQuery = query.toLowerCase();
return strategies.find(s => lowerQuery.includes(s)) || '';
}
private extractTeamRequirements(query: string): TeamRequirements {
return {
strategy: this.extractStrategy(query),
types: this.extractTypes(query),
tier: this.extractTier(query)
};
}
private extractPokemonRequirements(query: string): PokemonRequirements {
return {
types: this.extractTypes(query),
stats: this.extractStatRequirements(query)
};
}
private extractContext(query: string): SearchContext {
return {
tier: this.extractTier(query),
format: this.extractFormat(query)
};
}
private extractTier(query: string): string {
const tiers = ['OU', 'UU', 'RU', 'NU', 'PU', 'LC', 'Ubers'];
const upperQuery = query.toUpperCase();
return tiers.find(tier => upperQuery.includes(tier)) || '';
}
private extractFormat(query: string): string {
if (query.includes('doubles') || query.includes('2v2')) return 'doubles';
if (query.includes('singles') || query.includes('1v1')) return 'singles';
return 'singles';
}
private extractMoveNames(query: string): string[] {
const words = query.split(' ');
return words.filter(word =>
word.length > 3 &&
word[0] === word[0].toUpperCase()
);
}
private async fallbackLocalSearch(query: string): Promise<FormattedResults> {
// Implement basic fallback search logic
console.warn('Using fallback local search');
const intent = this.parseIntent(query);
return {
intent,
results: {
error: 'Backend service unavailable. Please try again later.'
},
suggestions: []
};
}
}
// Export new enhanced service
export const enhancedNlSearchService = new EnhancedNaturalLanguageSearchService();
// Note: Legacy naturalLanguageSearch service has been removed