feat: league tier system + retrained V25 models (48 quality leagues)
Deploy Iddaai Backend / build-and-deploy (push) Failing after 3m56s

- Add LeagueTier DB model and Prisma schema
- Add league-tiers service (CRUD, sync, retrain trigger)
- Add league-tiers controller with admin API endpoints
- Add /v1/admin/retrain endpoint in AI engine (extract→train→reload pipeline)
- Retrain V25 Pro with 48 quality leagues (MS accuracy: 26.9%→51.4%)
- Update qualified_leagues.json (443→48 leagues)
- Include V25 model files in repo for Docker deployment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 21:57:15 +03:00
parent e001ce9ab5
commit 21e05148c8
58 changed files with 112323 additions and 897 deletions
@@ -0,0 +1,122 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Param,
Body,
Query,
} from "@nestjs/common";
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiParam,
ApiQuery,
} from "@nestjs/swagger";
import { Roles } from "../../common/decorators";
import { LeagueTiersService } from "./league-tiers.service";
@ApiTags("League Tiers")
@ApiBearerAuth()
@Controller("admin/league-tiers")
@Roles("superadmin")
export class LeagueTiersController {
constructor(private readonly leagueTiersService: LeagueTiersService) {}
// ─── READ ──────────────────────────────────────────────────
@Get()
@ApiOperation({ summary: "Get all league tiers" })
@ApiResponse({ status: 200, description: "All league tiers" })
@ApiQuery({
name: "active",
required: false,
type: Boolean,
description: "Filter active only",
})
async findAll(@Query("active") active?: string) {
if (active === "true") {
return this.leagueTiersService.findActive();
}
return this.leagueTiersService.findAll();
}
@Get("stats")
@ApiOperation({ summary: "Get league tier statistics" })
@ApiResponse({ status: 200, description: "Tier statistics" })
async getStats() {
return this.leagueTiersService.getStats();
}
@Get("tier/:tier")
@ApiOperation({ summary: "Get leagues by tier level" })
@ApiParam({ name: "tier", description: "Tier level (1=Diamond, 2=Gold, 3=Silver)" })
async findByTier(@Param("tier") tier: string) {
return this.leagueTiersService.findByTier(parseInt(tier, 10));
}
// ─── WRITE ─────────────────────────────────────────────────
@Post()
@ApiOperation({ summary: "Add a league to tiers (upsert)" })
@ApiResponse({ status: 201, description: "League added/updated" })
async addLeague(
@Body()
body: {
leagueId: string;
tier?: number;
notes?: string;
addedBy?: string;
},
) {
return this.leagueTiersService.addLeague(
body.leagueId,
body.tier,
body.notes,
body.addedBy,
);
}
@Put(":leagueId/tier")
@ApiOperation({ summary: "Update league tier level" })
@ApiParam({ name: "leagueId", description: "League ID" })
async updateTier(
@Param("leagueId") leagueId: string,
@Body() body: { tier: number },
) {
return this.leagueTiersService.updateTier(leagueId, body.tier);
}
@Put(":leagueId/deactivate")
@ApiOperation({ summary: "Deactivate a league (soft remove)" })
@ApiParam({ name: "leagueId", description: "League ID" })
async removeLeague(@Param("leagueId") leagueId: string) {
return this.leagueTiersService.removeLeague(leagueId);
}
@Delete(":leagueId")
@ApiOperation({ summary: "Permanently delete a league tier" })
@ApiParam({ name: "leagueId", description: "League ID" })
async deleteLeague(@Param("leagueId") leagueId: string) {
return this.leagueTiersService.deleteLeague(leagueId);
}
// ─── SYNC & RETRAIN ───────────────────────────────────────
@Post("sync")
@ApiOperation({ summary: "Sync qualified_leagues.json from DB" })
@ApiResponse({ status: 200, description: "Sync result with count and path" })
async syncQualifiedLeagues() {
return this.leagueTiersService.syncQualifiedLeagues();
}
@Post("retrain")
@ApiOperation({ summary: "Trigger AI model retrain" })
@ApiResponse({ status: 200, description: "Retrain trigger result" })
async triggerRetrain() {
return this.leagueTiersService.triggerModelRetrain();
}
}
+216
View File
@@ -0,0 +1,216 @@
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../../database/prisma.service";
import { HttpService } from "@nestjs/axios";
import { firstValueFrom } from "rxjs";
import * as fs from "fs";
import * as path from "path";
@Injectable()
export class LeagueTiersService {
private readonly logger = new Logger(LeagueTiersService.name);
private readonly qualifiedLeaguesPath = path.join(
process.cwd(),
"qualified_leagues.json",
);
constructor(
private readonly prisma: PrismaService,
private readonly httpService: HttpService,
) {}
// ─── READ ──────────────────────────────────────────────────
async findAll() {
return this.prisma.leagueTier.findMany({
include: {
league: {
include: { country: true },
},
},
orderBy: [{ tier: "asc" }, { createdAt: "asc" }],
});
}
async findActive() {
return this.prisma.leagueTier.findMany({
where: { isActive: true },
include: {
league: {
include: { country: true },
},
},
orderBy: [{ tier: "asc" }, { createdAt: "asc" }],
});
}
async findByTier(tier: number) {
return this.prisma.leagueTier.findMany({
where: { tier, isActive: true },
include: {
league: {
include: { country: true },
},
},
orderBy: { createdAt: "asc" },
});
}
// ─── WRITE ─────────────────────────────────────────────────
async addLeague(
leagueId: string,
tier: number = 2,
notes?: string,
addedBy?: string,
) {
const result = await this.prisma.leagueTier.upsert({
where: { leagueId },
create: {
leagueId,
tier,
isActive: true,
notes,
addedBy,
},
update: {
tier,
isActive: true,
notes: notes || undefined,
addedBy: addedBy || undefined,
},
include: {
league: { include: { country: true } },
},
});
// Auto-sync and retrain
await this.syncQualifiedLeagues();
this.triggerModelRetrain().catch((err) =>
this.logger.error(`Retrain trigger failed: ${err.message}`),
);
return result;
}
async removeLeague(leagueId: string) {
const result = await this.prisma.leagueTier.update({
where: { leagueId },
data: { isActive: false },
});
await this.syncQualifiedLeagues();
return result;
}
async updateTier(leagueId: string, tier: number) {
return this.prisma.leagueTier.update({
where: { leagueId },
data: { tier },
});
}
async deleteLeague(leagueId: string) {
const result = await this.prisma.leagueTier.delete({
where: { leagueId },
});
await this.syncQualifiedLeagues();
return result;
}
// ─── SYNC qualified_leagues.json ───────────────────────────
async syncQualifiedLeagues(): Promise<{ count: number; path: string }> {
const activeTiers = await this.prisma.leagueTier.findMany({
where: { isActive: true },
select: { leagueId: true },
});
const leagueIds = activeTiers.map((t) => t.leagueId);
fs.writeFileSync(
this.qualifiedLeaguesPath,
JSON.stringify(leagueIds, null, 2),
"utf-8",
);
this.logger.log(
`Synced ${leagueIds.length} leagues to qualified_leagues.json`,
);
return { count: leagueIds.length, path: this.qualifiedLeaguesPath };
}
// ─── TRIGGER MODEL RETRAIN ─────────────────────────────────
async triggerModelRetrain(): Promise<{ status: string; message: string }> {
const aiEngineUrl = process.env.AI_ENGINE_URL || "http://localhost:8000";
try {
const response = await firstValueFrom(
this.httpService.post(
`${aiEngineUrl}/v1/admin/retrain`,
{ reason: "league_tier_change" },
{ timeout: 10000 },
),
);
this.logger.log(
`Model retrain triggered: ${JSON.stringify(response.data)}`,
);
return {
status: "triggered",
message: "Model retraining started in background",
};
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : String(error);
this.logger.warn(
`Could not trigger retrain (AI engine may not support it yet): ${message}`,
);
return {
status: "skipped",
message: `AI engine retrain endpoint not available: ${message}`,
};
}
}
// ─── STATS ─────────────────────────────────────────────────
async getStats() {
const tiers = await this.prisma.leagueTier.groupBy({
by: ["tier"],
where: { isActive: true },
_count: true,
});
const totalActive = tiers.reduce((sum, t) => sum + t._count, 0);
// Count matches in qualified leagues
const activeLeagueIds = (
await this.prisma.leagueTier.findMany({
where: { isActive: true },
select: { leagueId: true },
})
).map((t) => t.leagueId);
const matchCount = await this.prisma.match.count({
where: {
leagueId: { in: activeLeagueIds },
sport: "football",
status: "FT",
},
});
return {
tiers: tiers.map((t) => ({
tier: t.tier,
count: t._count,
})),
totalActiveLeagues: totalActive,
totalQualifiedMatches: matchCount,
};
}
}
+7 -4
View File
@@ -1,12 +1,15 @@
import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { LeaguesController } from "./leagues.controller";
import { LeagueTiersController } from "./league-tiers.controller";
import { LeaguesService } from "./leagues.service";
import { LeagueTiersService } from "./league-tiers.service";
import { DatabaseModule } from "../../database/database.module";
@Module({
imports: [DatabaseModule],
controllers: [LeaguesController],
providers: [LeaguesService],
exports: [LeaguesService],
imports: [DatabaseModule, HttpModule],
controllers: [LeaguesController, LeagueTiersController],
providers: [LeaguesService, LeagueTiersService],
exports: [LeaguesService, LeagueTiersService],
})
export class LeaguesModule {}