rating
This commit is contained in:
299
core/identity/domain/services/EligibilityEvaluator.ts
Normal file
299
core/identity/domain/services/EligibilityEvaluator.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Service: EligibilityEvaluator
|
||||
*
|
||||
* Pure domain service for DSL-based eligibility evaluation.
|
||||
* Parses DSL expressions and evaluates them against rating data.
|
||||
* Provides explainable results with detailed reasons.
|
||||
*/
|
||||
|
||||
import { EvaluationResultDto, EvaluationReason } from '../../application/dtos/EvaluationResultDto';
|
||||
import { EligibilityFilterDto, ParsedEligibilityFilter, EligibilityCondition } from '../../application/dtos/EligibilityFilterDto';
|
||||
|
||||
export interface RatingData {
|
||||
platform: {
|
||||
[dimension: string]: number;
|
||||
};
|
||||
external: {
|
||||
[game: string]: {
|
||||
[type: string]: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export class EligibilityEvaluator {
|
||||
/**
|
||||
* Main entry point: evaluate DSL against rating data
|
||||
*/
|
||||
evaluate(filter: EligibilityFilterDto, ratingData: RatingData): EvaluationResultDto {
|
||||
try {
|
||||
const parsed = this.parseDSL(filter.dsl);
|
||||
const reasons: EvaluationReason[] = [];
|
||||
|
||||
// Evaluate each condition
|
||||
for (const condition of parsed.conditions) {
|
||||
const reason = this.evaluateCondition(condition, ratingData);
|
||||
reasons.push(reason);
|
||||
}
|
||||
|
||||
// Determine overall eligibility based on logical operator
|
||||
const eligible = parsed.logicalOperator === 'AND'
|
||||
? reasons.every(r => !r.failed)
|
||||
: reasons.some(r => !r.failed);
|
||||
|
||||
// Build summary
|
||||
const summary = this.buildSummary(eligible, reasons, parsed.logicalOperator);
|
||||
|
||||
const metadata: Record<string, unknown> = {
|
||||
filter: filter.dsl,
|
||||
};
|
||||
|
||||
if (filter.context?.userId) {
|
||||
metadata.userId = filter.context.userId;
|
||||
}
|
||||
|
||||
return {
|
||||
eligible,
|
||||
reasons,
|
||||
summary,
|
||||
evaluatedAt: new Date().toISOString(),
|
||||
metadata,
|
||||
};
|
||||
} catch (error) {
|
||||
// Handle parsing errors
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown parsing error';
|
||||
|
||||
const metadata: Record<string, unknown> = {
|
||||
filter: filter.dsl,
|
||||
error: errorMessage,
|
||||
};
|
||||
|
||||
if (filter.context?.userId) {
|
||||
metadata.userId = filter.context.userId;
|
||||
}
|
||||
|
||||
return {
|
||||
eligible: false,
|
||||
reasons: [],
|
||||
summary: `Failed to evaluate filter: ${errorMessage}`,
|
||||
evaluatedAt: new Date().toISOString(),
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse DSL string into structured conditions
|
||||
* Supports: platform.{dim} >= 55 OR external.{game}.{type} between 2000 2500
|
||||
*/
|
||||
parseDSL(dsl: string): ParsedEligibilityFilter {
|
||||
// Normalize and tokenize
|
||||
const normalized = dsl.trim().replace(/\s+/g, ' ');
|
||||
|
||||
// Determine logical operator
|
||||
const hasOR = normalized.toUpperCase().includes(' OR ');
|
||||
const hasAND = normalized.toUpperCase().includes(' AND ');
|
||||
|
||||
if (hasOR && hasAND) {
|
||||
throw new Error('Mixed AND/OR not supported. Use parentheses or separate filters.');
|
||||
}
|
||||
|
||||
const logicalOperator = hasOR ? 'OR' : 'AND';
|
||||
const separator = hasOR ? ' OR ' : ' AND ';
|
||||
|
||||
// Split into individual conditions
|
||||
const conditionStrings = normalized.split(separator).map(s => s.trim());
|
||||
|
||||
const conditions: EligibilityCondition[] = conditionStrings.map(str => {
|
||||
return this.parseCondition(str);
|
||||
});
|
||||
|
||||
return {
|
||||
conditions,
|
||||
logicalOperator,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single condition string
|
||||
* Examples:
|
||||
* - "platform.driving >= 55"
|
||||
* - "external.iracing.iRating between 2000 2500"
|
||||
*/
|
||||
parseCondition(conditionStr: string): EligibilityCondition {
|
||||
// Check for "between" operator
|
||||
const betweenMatch = conditionStr.match(/^(.+?)\s+between\s+(\d+)\s+(\d+)$/i);
|
||||
if (betweenMatch) {
|
||||
const targetExpr = betweenMatch[1]?.trim();
|
||||
const minStr = betweenMatch[2];
|
||||
const maxStr = betweenMatch[3];
|
||||
|
||||
if (!targetExpr || !minStr || !maxStr) {
|
||||
throw new Error(`Invalid between condition: "${conditionStr}"`);
|
||||
}
|
||||
|
||||
const parsed = this.parseTargetExpression(targetExpr);
|
||||
|
||||
return {
|
||||
target: parsed.target,
|
||||
dimension: parsed.dimension,
|
||||
game: parsed.game,
|
||||
operator: 'between',
|
||||
expected: [parseFloat(minStr), parseFloat(maxStr)],
|
||||
} as unknown as EligibilityCondition;
|
||||
}
|
||||
|
||||
// Check for comparison operators
|
||||
const compareMatch = conditionStr.match(/^(.+?)\s*(>=|<=|>|<|=|!=)\s*(\d+(?:\.\d+)?)$/);
|
||||
if (compareMatch) {
|
||||
const targetExpr = compareMatch[1]?.trim();
|
||||
const operator = compareMatch[2];
|
||||
const valueStr = compareMatch[3];
|
||||
|
||||
if (!targetExpr || !operator || !valueStr) {
|
||||
throw new Error(`Invalid comparison condition: "${conditionStr}"`);
|
||||
}
|
||||
|
||||
const parsed = this.parseTargetExpression(targetExpr);
|
||||
|
||||
return {
|
||||
target: parsed.target,
|
||||
dimension: parsed.dimension,
|
||||
game: parsed.game,
|
||||
operator: operator as EligibilityCondition['operator'],
|
||||
expected: parseFloat(valueStr),
|
||||
} as unknown as EligibilityCondition;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid condition format: "${conditionStr}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse target expression like "platform.driving" or "external.iracing.iRating"
|
||||
*/
|
||||
private parseTargetExpression(expr: string): { target: 'platform' | 'external'; dimension?: string; game?: string } {
|
||||
const parts = expr.split('.');
|
||||
|
||||
if (parts[0] === 'platform') {
|
||||
if (parts.length !== 2) {
|
||||
throw new Error(`Invalid platform expression: "${expr}"`);
|
||||
}
|
||||
const dimension = parts[1];
|
||||
if (!dimension) {
|
||||
throw new Error(`Invalid platform expression: "${expr}"`);
|
||||
}
|
||||
return { target: 'platform', dimension };
|
||||
}
|
||||
|
||||
if (parts[0] === 'external') {
|
||||
if (parts.length !== 3) {
|
||||
throw new Error(`Invalid external expression: "${expr}"`);
|
||||
}
|
||||
const game = parts[1];
|
||||
const dimension = parts[2];
|
||||
if (!game || !dimension) {
|
||||
throw new Error(`Invalid external expression: "${expr}"`);
|
||||
}
|
||||
return { target: 'external', game, dimension };
|
||||
}
|
||||
|
||||
throw new Error(`Unknown target: "${parts[0]}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single condition against rating data
|
||||
*/
|
||||
private evaluateCondition(condition: EligibilityCondition, ratingData: RatingData): EvaluationReason {
|
||||
// Get actual value
|
||||
let actual: number | undefined;
|
||||
|
||||
if (condition.target === 'platform' && condition.dimension) {
|
||||
actual = ratingData.platform[condition.dimension];
|
||||
} else if (condition.target === 'external' && condition.game && condition.dimension) {
|
||||
actual = ratingData.external[condition.game]?.[condition.dimension];
|
||||
}
|
||||
|
||||
// Handle missing data
|
||||
if (actual === undefined || actual === null || isNaN(actual)) {
|
||||
return {
|
||||
target: condition.target === 'platform'
|
||||
? `platform.${condition.dimension}`
|
||||
: `external.${condition.game}.${condition.dimension}`,
|
||||
operator: condition.operator,
|
||||
expected: condition.expected,
|
||||
actual: 0,
|
||||
failed: true,
|
||||
message: `Missing data for ${condition.target === 'platform' ? `platform dimension "${condition.dimension}"` : `external game "${condition.game}" type "${condition.dimension}"`}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Evaluate based on operator
|
||||
let failed = false;
|
||||
|
||||
switch (condition.operator) {
|
||||
case '>=':
|
||||
failed = actual < (condition.expected as number);
|
||||
break;
|
||||
case '<=':
|
||||
failed = actual > (condition.expected as number);
|
||||
break;
|
||||
case '>':
|
||||
failed = actual <= (condition.expected as number);
|
||||
break;
|
||||
case '<':
|
||||
failed = actual >= (condition.expected as number);
|
||||
break;
|
||||
case '=':
|
||||
failed = actual !== (condition.expected as number);
|
||||
break;
|
||||
case '!=':
|
||||
failed = actual === (condition.expected as number);
|
||||
break;
|
||||
case 'between': {
|
||||
const [min, max] = condition.expected as [number, number];
|
||||
failed = actual < min || actual > max;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown operator: ${condition.operator}`);
|
||||
}
|
||||
|
||||
const targetStr = condition.target === 'platform'
|
||||
? `platform.${condition.dimension}`
|
||||
: `external.${condition.game}.${condition.dimension}`;
|
||||
|
||||
const expectedStr = condition.operator === 'between'
|
||||
? `${(condition.expected as [number, number])[0]} to ${(condition.expected as [number, number])[1]}`
|
||||
: `${condition.operator} ${condition.expected}`;
|
||||
|
||||
return {
|
||||
target: targetStr,
|
||||
operator: condition.operator,
|
||||
expected: condition.expected,
|
||||
actual,
|
||||
failed,
|
||||
message: failed
|
||||
? `Expected ${targetStr} ${expectedStr}, but got ${actual}`
|
||||
: `${targetStr} ${expectedStr} ✓`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build human-readable summary
|
||||
*/
|
||||
private buildSummary(eligible: boolean, reasons: EvaluationReason[], operator: 'AND' | 'OR'): string {
|
||||
const failedReasons = reasons.filter(r => r.failed);
|
||||
|
||||
if (eligible) {
|
||||
if (operator === 'OR') {
|
||||
return `Eligible: At least one condition met (${reasons.filter(r => !r.failed).length}/${reasons.length})`;
|
||||
}
|
||||
return `Eligible: All conditions met (${reasons.length}/${reasons.length})`;
|
||||
}
|
||||
|
||||
if (operator === 'AND') {
|
||||
return `Not eligible: ${failedReasons.length} condition(s) failed`;
|
||||
}
|
||||
|
||||
return `Not eligible: All conditions failed (${failedReasons.length}/${reasons.length})`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user