feat: league tier system + retrained V25 models (48 quality leagues)
Deploy Iddaai Backend / build-and-deploy (push) Failing after 3m56s
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user