@semanticintent/semantic-chirp-intelligence-mcp 3.1.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.chirp-data/opponent.json +24 -0
- package/.chirp-data/roster.json +34 -0
- package/.chirp-data/standings.json +17 -0
- package/.nhl-schedule-cache/20262027.json +1 -0
- package/.nhl-schedule-cache/players-20262027-20252026.json +1 -0
- package/README.md +129 -79
- package/build/analyses/BreakoutAnalysis.js +25 -36
- package/build/analyses/DraftPickAnalysis.js +431 -0
- package/build/analyses/GamesInHandAnalysis.js +52 -121
- package/build/analyses/IceAnalysis.js +66 -57
- package/build/analyses/LineupAnalysis.js +26 -46
- package/build/analyses/ScheduleValueAnalysis.js +279 -0
- package/build/analyses/StreamingAnalysis.js +40 -76
- package/build/analyses/WeekendStreamAnalysis.js +130 -66
- package/build/config/tool-metadata.js +54 -0
- package/build/domain/nhl-teams.js +67 -0
- package/build/index.js +561 -653
- package/build/services/LeagueDataService.js +130 -0
- package/build/services/NhlScheduleService.js +357 -0
- package/build/services/NhlStatsService.js +297 -0
- package/build/services/RosterStore.js +233 -0
- package/package.json +12 -10
- package/scripts/preflight.mjs +67 -0
- package/scripts/smoke.mjs +92 -0
- package/.env.example +0 -8
- package/authenticate.js +0 -207
- package/build/experimental/semantic-breakout-tool.js +0 -188
- package/build/experimental/semantic-intent-parser.js +0 -222
- package/build/experimental/semantic-tool-integration.js +0 -146
- package/build/experimental/test-parser.js +0 -61
- package/build/services/YahooApiClient.js +0 -309
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🎯 Draft Pick Analysis — ICE at the draft table
|
|
3
|
+
*
|
|
4
|
+
* Answers one question: with pick N on the clock, who should you take?
|
|
5
|
+
*
|
|
6
|
+
* A public draft board ranks players in the abstract. This ranks them against
|
|
7
|
+
* *your* draft: who is already gone, what your roster is still missing, what
|
|
8
|
+
* your league's categories reward, and what each player's club schedule is
|
|
9
|
+
* worth in the weeks your league plays its playoffs.
|
|
10
|
+
*
|
|
11
|
+
* Value is measured as ADP delta — Yahoo's own average draft position minus
|
|
12
|
+
* the pick actually on the clock — so "value" means the market is wrong here,
|
|
13
|
+
* not "this player is good".
|
|
14
|
+
*
|
|
15
|
+
* Semantic Identity: ICE - Intent Chirp Engine (Draft)
|
|
16
|
+
* Intent: I tell you who to take, and why the board is wrong
|
|
17
|
+
* Chirp Style: ice_cold_truth
|
|
18
|
+
*/
|
|
19
|
+
import { AnalysisTemplate } from '../template/AnalysisTemplate.js';
|
|
20
|
+
import { ChirpIntelligence } from '../services/ChirpIntelligence.js';
|
|
21
|
+
import { NHL_SCHEDULE, NhlScheduleService } from '../services/NhlScheduleService.js';
|
|
22
|
+
import { toNhlTricode } from '../domain/nhl-teams.js';
|
|
23
|
+
import { LEAGUE_DATA, LeagueDataService } from '../services/LeagueDataService.js';
|
|
24
|
+
import { NHL_STATS } from '../services/NhlStatsService.js';
|
|
25
|
+
/** Skater and goalie slots this analysis reasons about. */
|
|
26
|
+
const TRACKED_POSITIONS = ['C', 'LW', 'RW', 'D', 'G'];
|
|
27
|
+
export class DraftPickAnalysis extends AnalysisTemplate {
|
|
28
|
+
constructor() {
|
|
29
|
+
super('chirp_draft_pick', 'draft_pick');
|
|
30
|
+
}
|
|
31
|
+
// ==========================================
|
|
32
|
+
// Hook 1: Fetch
|
|
33
|
+
// ==========================================
|
|
34
|
+
async fetchData(args) {
|
|
35
|
+
await Promise.all([NHL_STATS.load(), NHL_SCHEDULE.load(), NHL_SCHEDULE.loadStandings()]);
|
|
36
|
+
// v4: the board is every NHL player, ranked on last season's production.
|
|
37
|
+
// Yahoo's ADP is gone, so "value" is measured against production rank
|
|
38
|
+
// rather than against where a market drafts a player.
|
|
39
|
+
return {
|
|
40
|
+
pool: LEAGUE_DATA.getPlayerPool({ limit: Math.min(args.pool_size ?? 250, 400) }),
|
|
41
|
+
roster: LEAGUE_DATA.getRoster(),
|
|
42
|
+
pool_caveat: LeagueDataService.POOL_CAVEAT
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
// ==========================================
|
|
46
|
+
// Hook 2: Prepare
|
|
47
|
+
// ==========================================
|
|
48
|
+
async prepareData(rawData, args) {
|
|
49
|
+
// Board state comes entirely from what the user tells us — there is no
|
|
50
|
+
// platform to ask. `already_drafted` is therefore the source, not a
|
|
51
|
+
// fallback, and the pick number follows from it unless stated.
|
|
52
|
+
const draftedNames = new Set((args.already_drafted ?? []).map(n => this.normalizeName(n)));
|
|
53
|
+
const rosterPositions = {};
|
|
54
|
+
for (const p of rawData.roster?.players ?? []) {
|
|
55
|
+
for (const pos of String(p.position ?? '').split(',')) {
|
|
56
|
+
const clean = pos.trim().toUpperCase();
|
|
57
|
+
if (TRACKED_POSITIONS.includes(clean)) {
|
|
58
|
+
rosterPositions[clean] = (rosterPositions[clean] ?? 0) + 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Rank the pool by production; that rank stands in for a draft board.
|
|
63
|
+
const pool = (rawData.pool ?? []).map((p, index) => ({
|
|
64
|
+
player_id: p.player_id,
|
|
65
|
+
name: p.name,
|
|
66
|
+
position: p.position,
|
|
67
|
+
positions: String(p.position ?? '').split(',').map((x) => x.trim().toUpperCase()).filter(Boolean),
|
|
68
|
+
team: p.team,
|
|
69
|
+
// Production rank is the board position: the Nth best producer is,
|
|
70
|
+
// absent a market, the Nth pick worth making.
|
|
71
|
+
average_pick: index + 1,
|
|
72
|
+
average_round: null,
|
|
73
|
+
percent_drafted: null,
|
|
74
|
+
stats: p.stats ?? null
|
|
75
|
+
}));
|
|
76
|
+
return {
|
|
77
|
+
draftPool: pool,
|
|
78
|
+
draftedIds: new Set(),
|
|
79
|
+
draftedNames,
|
|
80
|
+
draftedCount: draftedNames.size,
|
|
81
|
+
draftResultsAvailable: false,
|
|
82
|
+
manualDraftedCount: draftedNames.size,
|
|
83
|
+
rosterPositions,
|
|
84
|
+
playoffWindow: this.resolvePlayoffWindow(null),
|
|
85
|
+
poolCaveat: rawData.pool_caveat
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// ==========================================
|
|
89
|
+
// Hook 3: Analyze
|
|
90
|
+
// ==========================================
|
|
91
|
+
async analyzeData(data, args) {
|
|
92
|
+
const d = data;
|
|
93
|
+
const maxResults = args.max_results ?? 8;
|
|
94
|
+
// Pick on the clock: explicit, else inferred from picks already made.
|
|
95
|
+
const pickNumber = args.pick_number ?? (Math.max(d.draftedCount, d.draftedNames.size) + 1);
|
|
96
|
+
const needs = args.roster_needs?.length
|
|
97
|
+
? args.roster_needs.map(p => p.toUpperCase())
|
|
98
|
+
: this.inferNeeds(d.rosterPositions);
|
|
99
|
+
// Yahoo's REST draft results can lag a fast live draft, so `already_drafted`
|
|
100
|
+
// is a first-class second source rather than a fallback: a player is off
|
|
101
|
+
// the board if either source says so.
|
|
102
|
+
const available = d.draftPool.filter((p) => !d.draftedIds.has(String(p.player_id)) &&
|
|
103
|
+
!d.draftedNames.has(this.normalizeName(p.name)));
|
|
104
|
+
const candidates = available
|
|
105
|
+
.map((p) => this.rateCandidate(p, pickNumber, needs, d.playoffWindow))
|
|
106
|
+
.sort((a, b) => b.draft_score - a.draft_score)
|
|
107
|
+
.slice(0, maxResults);
|
|
108
|
+
return {
|
|
109
|
+
pick_number: pickNumber,
|
|
110
|
+
pick_number_source: args.pick_number ? 'explicit' : 'inferred from draft results',
|
|
111
|
+
roster_needs: needs,
|
|
112
|
+
pool_size: d.draftPool.length,
|
|
113
|
+
available_count: available.length,
|
|
114
|
+
players_off_board: d.draftedIds.size + d.draftedNames.size,
|
|
115
|
+
draft_results_available: d.draftResultsAvailable,
|
|
116
|
+
manual_drafted_count: d.manualDraftedCount,
|
|
117
|
+
playoff_window: d.playoffWindow,
|
|
118
|
+
pool_caveat: d.poolCaveat,
|
|
119
|
+
schedule_available: NHL_SCHEDULE.isAvailable(),
|
|
120
|
+
candidates
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// ==========================================
|
|
124
|
+
// Hook 4: Chirp
|
|
125
|
+
// ==========================================
|
|
126
|
+
async generateChirp(analysisResults, semanticContract, data) {
|
|
127
|
+
const contract = this.mergeContractWithDefaults(semanticContract);
|
|
128
|
+
const enhanced = ChirpIntelligence.enhance(this.toolName, analysisResults, contract);
|
|
129
|
+
const top = analysisResults.candidates[0];
|
|
130
|
+
let chirp;
|
|
131
|
+
if (!top) {
|
|
132
|
+
chirp = 'Nobody left worth chirping about. Either the pool is empty or everyone is drafted.';
|
|
133
|
+
}
|
|
134
|
+
else if (top.adp_delta !== null && top.adp_delta >= 12) {
|
|
135
|
+
chirp =
|
|
136
|
+
`${top.name} is still sitting there at ${analysisResults.pick_number} and the room ` +
|
|
137
|
+
`usually takes him at ${top.average_pick}. That is ${Math.round(top.adp_delta)} picks of ` +
|
|
138
|
+
`free value. Take him before someone wakes up.`;
|
|
139
|
+
}
|
|
140
|
+
else if (top.fills_need) {
|
|
141
|
+
chirp =
|
|
142
|
+
`${top.name} fills the hole you actually have (${top.position}), and ` +
|
|
143
|
+
`${top.team} plays ${top.playoff_games ?? '?'} games in your playoff window. ` +
|
|
144
|
+
`Best available is a luxury; a full lineup is not.`;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
chirp =
|
|
148
|
+
`${top.name} is the pick. No bargain, no drama — just the best board-and-schedule ` +
|
|
149
|
+
`combination left at ${analysisResults.pick_number}.`;
|
|
150
|
+
}
|
|
151
|
+
if (analysisResults.players_off_board === 0) {
|
|
152
|
+
chirp +=
|
|
153
|
+
' ⚠️ Nobody is marked as drafted yet — pass `already_drafted` as picks go by ' +
|
|
154
|
+
'and this board stays accurate.';
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
...enhanced,
|
|
158
|
+
chirp_intelligence: {
|
|
159
|
+
...enhanced.chirp_intelligence,
|
|
160
|
+
analysis_chirp: chirp
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// ==========================================
|
|
165
|
+
// Hook 5: Format
|
|
166
|
+
// ==========================================
|
|
167
|
+
async formatResponse(chirpEnhanced, data) {
|
|
168
|
+
const candidates = chirpEnhanced.candidates ?? [];
|
|
169
|
+
const recommendations = candidates.map((c, index) => ({
|
|
170
|
+
priority: index === 0 ? 'CRITICAL' : index < 3 ? 'HIGH' : 'MEDIUM',
|
|
171
|
+
action: 'draft',
|
|
172
|
+
reasoning: c.reasoning
|
|
173
|
+
}));
|
|
174
|
+
const analysisInsights = {
|
|
175
|
+
pick_number: chirpEnhanced.pick_number,
|
|
176
|
+
pick_number_source: chirpEnhanced.pick_number_source,
|
|
177
|
+
roster_needs: chirpEnhanced.roster_needs,
|
|
178
|
+
players_off_board: chirpEnhanced.players_off_board,
|
|
179
|
+
board_state: `${chirpEnhanced.players_off_board} player(s) marked as already drafted. ` +
|
|
180
|
+
'Board state comes from `already_drafted` — there is no platform to read it from, ' +
|
|
181
|
+
'so tell me who has gone and the board updates.',
|
|
182
|
+
board_note: chirpEnhanced.pool_caveat,
|
|
183
|
+
schedule_source: chirpEnhanced.schedule_available
|
|
184
|
+
? `NHL public API (season ${NHL_SCHEDULE.getSeason()})`
|
|
185
|
+
: `UNAVAILABLE - ${NHL_SCHEDULE.getUnavailableReason()}; schedule value excluded from scoring`,
|
|
186
|
+
playoff_window: chirpEnhanced.playoff_window,
|
|
187
|
+
top_candidates: candidates
|
|
188
|
+
};
|
|
189
|
+
const metadata = {
|
|
190
|
+
analysis_type: this.analysisType,
|
|
191
|
+
timestamp: new Date().toISOString(),
|
|
192
|
+
team_context: { team_name: 'Draft Board' },
|
|
193
|
+
semantic_contract_applied: true
|
|
194
|
+
};
|
|
195
|
+
return {
|
|
196
|
+
analysis_insights: analysisInsights,
|
|
197
|
+
recommendations,
|
|
198
|
+
chirp_intelligence: chirpEnhanced.chirp_intelligence,
|
|
199
|
+
metadata
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
// ==========================================
|
|
203
|
+
// 🎯 Scoring
|
|
204
|
+
// ==========================================
|
|
205
|
+
rateCandidate(player, pickNumber, needs, window) {
|
|
206
|
+
const averagePick = player.average_pick;
|
|
207
|
+
const adpDelta = averagePick !== null ? pickNumber - averagePick : null;
|
|
208
|
+
const tricode = toNhlTricode(player.team);
|
|
209
|
+
const profile = tricode && NHL_SCHEDULE.isAvailable()
|
|
210
|
+
? NHL_SCHEDULE.getTeamProfile(tricode)
|
|
211
|
+
: null;
|
|
212
|
+
const playoffGames = profile && window?.start && window?.end
|
|
213
|
+
? NHL_SCHEDULE.countGamesInRange(tricode, window.start, window.end)
|
|
214
|
+
: null;
|
|
215
|
+
const fillsNeed = needs.some(need => player.positions.includes(need));
|
|
216
|
+
// --- components, each 0-1 ---
|
|
217
|
+
// Value: how far past his usual draft slot he has fallen. A player going
|
|
218
|
+
// 20 picks later than the market takes him is the whole point.
|
|
219
|
+
const valueComponent = adpDelta === null
|
|
220
|
+
? 0.5
|
|
221
|
+
: Math.max(0, Math.min(1, (adpDelta + 10) / 40));
|
|
222
|
+
// Board rank: earlier average pick is a better player, all else equal.
|
|
223
|
+
const rankComponent = averagePick === null
|
|
224
|
+
? 0.3
|
|
225
|
+
: Math.max(0, Math.min(1, 1 - (averagePick / 250)));
|
|
226
|
+
// Schedule: playoff-window volume, normalized against a 4-game week.
|
|
227
|
+
const weekCount = window?.weeks?.length ?? 0;
|
|
228
|
+
const scheduleComponent = playoffGames !== null && weekCount > 0
|
|
229
|
+
? Math.min(1, (playoffGames / weekCount) / 4)
|
|
230
|
+
: 0.5;
|
|
231
|
+
const needComponent = fillsNeed ? 1 : 0;
|
|
232
|
+
const score = (0.40 * valueComponent) +
|
|
233
|
+
(0.30 * rankComponent) +
|
|
234
|
+
(0.18 * needComponent) +
|
|
235
|
+
(0.12 * scheduleComponent);
|
|
236
|
+
return {
|
|
237
|
+
player_id: player.player_id,
|
|
238
|
+
name: player.name,
|
|
239
|
+
position: player.position,
|
|
240
|
+
team: player.team,
|
|
241
|
+
average_pick: averagePick,
|
|
242
|
+
percent_drafted: player.percent_drafted,
|
|
243
|
+
adp_delta: adpDelta === null ? null : Number(adpDelta.toFixed(1)),
|
|
244
|
+
playoff_games: playoffGames,
|
|
245
|
+
four_game_weeks: profile?.weeks_with_4_plus ?? null,
|
|
246
|
+
fills_need: fillsNeed,
|
|
247
|
+
draft_score: Math.round(score * 100),
|
|
248
|
+
verdict: this.verdictFor(adpDelta),
|
|
249
|
+
reasoning: this.reasoningFor(player, adpDelta, playoffGames, weekCount, fillsNeed)
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
verdictFor(adpDelta) {
|
|
253
|
+
if (adpDelta === null)
|
|
254
|
+
return 'FAIR';
|
|
255
|
+
if (adpDelta >= 20)
|
|
256
|
+
return 'STEAL';
|
|
257
|
+
if (adpDelta >= 8)
|
|
258
|
+
return 'VALUE';
|
|
259
|
+
if (adpDelta <= -12)
|
|
260
|
+
return 'REACH';
|
|
261
|
+
return 'FAIR';
|
|
262
|
+
}
|
|
263
|
+
reasoningFor(player, adpDelta, playoffGames, weekCount, fillsNeed) {
|
|
264
|
+
const parts = [`${player.name} (${player.position}, ${player.team})`];
|
|
265
|
+
if (adpDelta === null) {
|
|
266
|
+
parts.push('no Yahoo ADP available');
|
|
267
|
+
}
|
|
268
|
+
else if (adpDelta > 0) {
|
|
269
|
+
parts.push(`${Math.round(adpDelta)} picks past his ADP of ${player.average_pick}`);
|
|
270
|
+
}
|
|
271
|
+
else if (adpDelta < 0) {
|
|
272
|
+
parts.push(`${Math.abs(Math.round(adpDelta))} picks ahead of his ADP of ${player.average_pick}`);
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
parts.push(`right at his ADP of ${player.average_pick}`);
|
|
276
|
+
}
|
|
277
|
+
if (playoffGames !== null && weekCount > 0) {
|
|
278
|
+
parts.push(`${playoffGames} games across your ${weekCount} playoff weeks`);
|
|
279
|
+
}
|
|
280
|
+
if (fillsNeed)
|
|
281
|
+
parts.push('fills a roster hole');
|
|
282
|
+
return parts.join(' — ') + '.';
|
|
283
|
+
}
|
|
284
|
+
// ==========================================
|
|
285
|
+
// 🎯 Roster needs
|
|
286
|
+
// ==========================================
|
|
287
|
+
/** Positions with the thinnest coverage on the current roster. */
|
|
288
|
+
inferNeeds(rosterPositions) {
|
|
289
|
+
const counts = TRACKED_POSITIONS.map(pos => ({ pos, count: rosterPositions[pos] ?? 0 }));
|
|
290
|
+
const minimum = Math.min(...counts.map(c => c.count));
|
|
291
|
+
return counts.filter(c => c.count === minimum).map(c => c.pos);
|
|
292
|
+
}
|
|
293
|
+
// ==========================================
|
|
294
|
+
// 🎯 Yahoo parsing (defensive)
|
|
295
|
+
// ==========================================
|
|
296
|
+
/**
|
|
297
|
+
* Yahoo's fantasy JSON alternates between arrays and count-keyed objects
|
|
298
|
+
* depending on the resource and, in places, the request. Everything below
|
|
299
|
+
* accepts either shape and returns empty rather than throwing, so a shape
|
|
300
|
+
* change degrades one field instead of the whole draft tool.
|
|
301
|
+
*/
|
|
302
|
+
keyedEntries(container) {
|
|
303
|
+
if (!container)
|
|
304
|
+
return [];
|
|
305
|
+
if (Array.isArray(container))
|
|
306
|
+
return container;
|
|
307
|
+
return Object.keys(container)
|
|
308
|
+
.filter(k => k !== 'count')
|
|
309
|
+
.map(k => container[k]);
|
|
310
|
+
}
|
|
311
|
+
parsePlayerPool(poolPages) {
|
|
312
|
+
const players = [];
|
|
313
|
+
for (const page of poolPages ?? []) {
|
|
314
|
+
if (!page)
|
|
315
|
+
continue;
|
|
316
|
+
const container = page?.fantasy_content?.league?.[1]?.players ??
|
|
317
|
+
page?.fantasy_content?.league?.players;
|
|
318
|
+
for (const entry of this.keyedEntries(container)) {
|
|
319
|
+
const player = entry?.player;
|
|
320
|
+
if (!player)
|
|
321
|
+
continue;
|
|
322
|
+
const identity = Array.isArray(player[0]) ? player[0] : [];
|
|
323
|
+
const find = (key) => identity.find((item) => item?.[key])?.[key];
|
|
324
|
+
const name = find('name')?.full;
|
|
325
|
+
const playerId = find('player_id');
|
|
326
|
+
if (!name || !playerId)
|
|
327
|
+
continue;
|
|
328
|
+
// draft_analysis rides on the second element, occasionally nested.
|
|
329
|
+
const analysis = player[1]?.draft_analysis ??
|
|
330
|
+
player[1]?.[0]?.draft_analysis ??
|
|
331
|
+
(Array.isArray(player[1])
|
|
332
|
+
? player[1].find((item) => item?.draft_analysis)?.draft_analysis
|
|
333
|
+
: undefined);
|
|
334
|
+
const displayPosition = find('display_position') ?? '';
|
|
335
|
+
players.push({
|
|
336
|
+
player_id: String(playerId),
|
|
337
|
+
name,
|
|
338
|
+
position: displayPosition,
|
|
339
|
+
positions: String(displayPosition)
|
|
340
|
+
.split(',')
|
|
341
|
+
.map((p) => p.trim().toUpperCase())
|
|
342
|
+
.filter(Boolean),
|
|
343
|
+
team: find('editorial_team_abbr') ?? '',
|
|
344
|
+
average_pick: this.toNumberOrNull(analysis?.average_pick),
|
|
345
|
+
average_round: this.toNumberOrNull(analysis?.average_round),
|
|
346
|
+
percent_drafted: this.toNumberOrNull(analysis?.percent_drafted)
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// Same player can appear across pages; keep one.
|
|
351
|
+
return Array.from(new Map(players.map(p => [p.player_id, p])).values());
|
|
352
|
+
}
|
|
353
|
+
parseDraftResults(payload) {
|
|
354
|
+
const container = payload?.fantasy_content?.league?.[1]?.draft_results ??
|
|
355
|
+
payload?.fantasy_content?.league?.draft_results;
|
|
356
|
+
const entries = this.keyedEntries(container);
|
|
357
|
+
if (entries.length === 0) {
|
|
358
|
+
return { available: false, count: 0, ids: new Set() };
|
|
359
|
+
}
|
|
360
|
+
const ids = new Set();
|
|
361
|
+
let count = 0;
|
|
362
|
+
for (const entry of entries) {
|
|
363
|
+
const result = entry?.draft_result ?? entry;
|
|
364
|
+
if (!result?.player_key)
|
|
365
|
+
continue;
|
|
366
|
+
count++;
|
|
367
|
+
// Draft results carry player_key (`nhl.p.1234`), never a name, so the
|
|
368
|
+
// board state is matched to the pool by id.
|
|
369
|
+
const id = String(result.player_key).split('.').pop();
|
|
370
|
+
if (id)
|
|
371
|
+
ids.add(id);
|
|
372
|
+
}
|
|
373
|
+
return { available: count > 0, count, ids };
|
|
374
|
+
}
|
|
375
|
+
parseRosterPositions(payload) {
|
|
376
|
+
const counts = {};
|
|
377
|
+
const container = payload?.fantasy_content?.team?.[1]?.roster?.['0']?.players ??
|
|
378
|
+
payload?.fantasy_content?.team?.[1]?.roster?.players;
|
|
379
|
+
for (const entry of this.keyedEntries(container)) {
|
|
380
|
+
const player = entry?.player;
|
|
381
|
+
if (!player)
|
|
382
|
+
continue;
|
|
383
|
+
const identity = Array.isArray(player[0]) ? player[0] : [];
|
|
384
|
+
const displayPosition = identity.find((item) => item?.display_position)?.display_position;
|
|
385
|
+
for (const pos of String(displayPosition ?? '').split(',')) {
|
|
386
|
+
const clean = pos.trim().toUpperCase();
|
|
387
|
+
if (TRACKED_POSITIONS.includes(clean)) {
|
|
388
|
+
counts[clean] = (counts[clean] ?? 0) + 1;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return counts;
|
|
393
|
+
}
|
|
394
|
+
resolvePlayoffWindow(settings) {
|
|
395
|
+
const leagueMeta = settings?.fantasy_content?.league?.[0] ?? {};
|
|
396
|
+
const leagueSettings = settings?.fantasy_content?.league?.[1]?.settings?.[0] ?? {};
|
|
397
|
+
const startDate = leagueMeta.start_date;
|
|
398
|
+
const endWeek = Number(leagueMeta.end_week ?? leagueSettings.end_week ?? 0);
|
|
399
|
+
const playoffStartWeek = Number(leagueSettings.playoff_start_week ?? 0);
|
|
400
|
+
if (!startDate || !playoffStartWeek || !endWeek || playoffStartWeek > endWeek) {
|
|
401
|
+
return { resolved: false, start: null, end: null, weeks: [] };
|
|
402
|
+
}
|
|
403
|
+
const week1Monday = NhlScheduleService.weekStart(startDate);
|
|
404
|
+
const weeks = [];
|
|
405
|
+
for (let week = playoffStartWeek; week <= endWeek; week++) {
|
|
406
|
+
weeks.push(NhlScheduleService.addDays(week1Monday, (week - 1) * 7));
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
resolved: true,
|
|
410
|
+
playoff_start_week: playoffStartWeek,
|
|
411
|
+
end_week: endWeek,
|
|
412
|
+
start: NhlScheduleService.addDays(week1Monday, (playoffStartWeek - 1) * 7),
|
|
413
|
+
end: NhlScheduleService.addDays(week1Monday, endWeek * 7 - 1),
|
|
414
|
+
weeks
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
toNumberOrNull(value) {
|
|
418
|
+
if (value === undefined || value === null || value === '' || value === '-')
|
|
419
|
+
return null;
|
|
420
|
+
const parsed = parseFloat(value);
|
|
421
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
422
|
+
}
|
|
423
|
+
/** Case- and punctuation-insensitive name key for matching drafted players. */
|
|
424
|
+
normalizeName(name) {
|
|
425
|
+
return String(name)
|
|
426
|
+
.toLowerCase()
|
|
427
|
+
.normalize('NFD')
|
|
428
|
+
.replace(/[̀-ͯ]/g, '')
|
|
429
|
+
.replace(/[^a-z0-9]/g, '');
|
|
430
|
+
}
|
|
431
|
+
}
|
|
@@ -7,133 +7,36 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { AnalysisTemplate } from '../template/AnalysisTemplate.js';
|
|
9
9
|
import { ChirpIntelligence } from '../services/ChirpIntelligence.js';
|
|
10
|
+
import { NHL_SCHEDULE, NhlScheduleService } from '../services/NhlScheduleService.js';
|
|
11
|
+
import { LEAGUE_DATA, NO_ROSTER_MESSAGE, NO_OPPONENT_MESSAGE } from '../services/LeagueDataService.js';
|
|
12
|
+
import { NHL_STATS } from '../services/NhlStatsService.js';
|
|
10
13
|
export class GamesInHandAnalysis extends AnalysisTemplate {
|
|
11
|
-
|
|
12
|
-
leagueId;
|
|
13
|
-
teamId;
|
|
14
|
-
constructor(apiClient, leagueId, teamId) {
|
|
14
|
+
constructor() {
|
|
15
15
|
super("get_games_in_hand", "schedule_advantage");
|
|
16
|
-
this.apiClient = apiClient;
|
|
17
|
-
this.leagueId = leagueId;
|
|
18
|
-
this.teamId = teamId;
|
|
19
16
|
}
|
|
20
17
|
/**
|
|
21
18
|
* Hook 1: Fetch raw data from Yahoo API
|
|
22
19
|
*/
|
|
23
20
|
async fetchData(args) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
catch (error) {
|
|
35
|
-
throw new Error(`Failed to fetch games in hand data: ${error}`);
|
|
36
|
-
}
|
|
21
|
+
await Promise.all([NHL_STATS.load(), NHL_SCHEDULE.load()]);
|
|
22
|
+
const roster = LEAGUE_DATA.getRoster();
|
|
23
|
+
if (!roster)
|
|
24
|
+
throw new Error(NO_ROSTER_MESSAGE);
|
|
25
|
+
const opponent = LEAGUE_DATA.getOpponentRoster();
|
|
26
|
+
if (!opponent)
|
|
27
|
+
throw new Error(NO_OPPONENT_MESSAGE);
|
|
28
|
+
return { roster, opponent };
|
|
37
29
|
}
|
|
38
30
|
/**
|
|
39
31
|
* Hook 2: Prepare data into FantasyData structure
|
|
40
32
|
*/
|
|
41
33
|
async prepareData(rawData, args) {
|
|
42
|
-
const { matchup, scoreboard } = rawData;
|
|
43
|
-
// Parse matchup to find opponent - try multiple potential structures
|
|
44
|
-
let teams = matchup.fantasy_content?.team?.[1]?.matchup?.['0']?.teams?.['0']?.team || [];
|
|
45
|
-
// If teams is empty, try alternative structure (matchups array)
|
|
46
|
-
if (teams.length === 0) {
|
|
47
|
-
const matchups = matchup.fantasy_content?.team?.[1]?.matchups;
|
|
48
|
-
if (matchups && matchups.count !== '0') {
|
|
49
|
-
// Find the current matchup where status === "midevent"
|
|
50
|
-
const matchupKeys = Object.keys(matchups).filter(key => key !== 'count');
|
|
51
|
-
const currentMatchupKey = matchupKeys.find(key => {
|
|
52
|
-
const matchupData = matchups[key]?.matchup?.[0] || matchups[key]?.matchup;
|
|
53
|
-
return matchupData?.status === 'midevent';
|
|
54
|
-
});
|
|
55
|
-
// Use current matchup if found, otherwise fallback to last matchup
|
|
56
|
-
const selectedKey = currentMatchupKey || matchupKeys[matchupKeys.length - 1];
|
|
57
|
-
if (selectedKey && matchups[selectedKey]?.matchup?.[0]) {
|
|
58
|
-
teams = matchups[selectedKey].matchup[0].teams?.['0']?.team || [];
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
// Handle both full team IDs (nhl.l.123.t.1) and partial (just the number)
|
|
63
|
-
const cleanLeagueId = this.leagueId.replace(/^nhl\.l\./, '');
|
|
64
|
-
const cleanTeamId = this.teamId.replace(/^.*\.t\./, '');
|
|
65
|
-
const fullTeamKey = `nhl.l.${cleanLeagueId}.t.${cleanTeamId}`;
|
|
66
|
-
const yourTeam = teams.find((t) => {
|
|
67
|
-
// Team data can be nested as an array or direct object
|
|
68
|
-
const teamData = Array.isArray(t) ? t[0] : t;
|
|
69
|
-
const teamKey = teamData?.team_key;
|
|
70
|
-
return teamKey === fullTeamKey || teamKey === this.teamId || teamKey?.endsWith(`.t.${cleanTeamId}`);
|
|
71
|
-
});
|
|
72
|
-
const opponentTeam = teams.find((t) => {
|
|
73
|
-
const teamData = Array.isArray(t) ? t[0] : t;
|
|
74
|
-
const teamKey = teamData?.team_key;
|
|
75
|
-
return teamKey && teamKey !== fullTeamKey && !teamKey?.endsWith(`.t.${cleanTeamId}`);
|
|
76
|
-
});
|
|
77
|
-
if (!yourTeam || !opponentTeam) {
|
|
78
|
-
throw new Error(`Could not find matchup data. Teams found: ${teams.length}, Structure: ${JSON.stringify(matchup.fantasy_content?.team?.[1], null, 2).substring(0, 500)}`);
|
|
79
|
-
}
|
|
80
|
-
// Extract team data (handle both array and object structures)
|
|
81
|
-
const yourTeamData = Array.isArray(yourTeam) ? yourTeam[0] : yourTeam;
|
|
82
|
-
const opponentTeamData = Array.isArray(opponentTeam) ? opponentTeam[0] : opponentTeam;
|
|
83
|
-
// Extract team rosters
|
|
84
|
-
const yourRoster = yourTeamData.roster?.players || [];
|
|
85
|
-
const opponentRoster = opponentTeamData.roster?.players || [];
|
|
86
|
-
// Parse player data using .find() pattern like IceAnalysis and LineupAnalysis
|
|
87
|
-
const parsePlayer = (playerData) => {
|
|
88
|
-
const player = playerData.player?.[0] || playerData;
|
|
89
|
-
// Use .find() pattern since player is an array of property objects
|
|
90
|
-
const player_id = Array.isArray(player)
|
|
91
|
-
? player.find((item) => item.player_id)?.player_id
|
|
92
|
-
: player.player_id;
|
|
93
|
-
const name = Array.isArray(player)
|
|
94
|
-
? player.find((item) => item.name)?.name?.full
|
|
95
|
-
: player.name?.full;
|
|
96
|
-
const position = Array.isArray(player)
|
|
97
|
-
? player.find((item) => item.display_position)?.display_position ||
|
|
98
|
-
player.find((item) => item.primary_positions)?.primary_positions?.[0]
|
|
99
|
-
: player.display_position || player.primary_positions?.[0];
|
|
100
|
-
const team = Array.isArray(player)
|
|
101
|
-
? player.find((item) => item.editorial_team_abbr)?.editorial_team_abbr
|
|
102
|
-
: player.editorial_team_abbr;
|
|
103
|
-
const status = Array.isArray(player)
|
|
104
|
-
? player.find((item) => item.status)?.status
|
|
105
|
-
: player.status;
|
|
106
|
-
const selected_position = Array.isArray(player)
|
|
107
|
-
? player.find((item) => item.selected_position)?.selected_position
|
|
108
|
-
: player.selected_position;
|
|
109
|
-
return {
|
|
110
|
-
player_id: player_id || '',
|
|
111
|
-
name: name || 'Unknown',
|
|
112
|
-
position: position || 'Unknown',
|
|
113
|
-
team: team || '',
|
|
114
|
-
selected_position: selected_position || [],
|
|
115
|
-
status: status || ''
|
|
116
|
-
};
|
|
117
|
-
};
|
|
118
|
-
const yourPlayers = yourRoster.map(parsePlayer);
|
|
119
|
-
const opponentPlayers = opponentRoster.map(parsePlayer);
|
|
120
34
|
return {
|
|
121
|
-
roster:
|
|
122
|
-
team_key: yourTeamData.team_key || '',
|
|
123
|
-
team_name: yourTeamData.name || 'Your Team',
|
|
124
|
-
players: yourPlayers
|
|
125
|
-
},
|
|
35
|
+
roster: rawData.roster,
|
|
126
36
|
opponent: {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
},
|
|
131
|
-
matchup: {
|
|
132
|
-
week: matchup.fantasy_content?.team?.[0]?.matchups?.['0']?.matchup?.week || 'current',
|
|
133
|
-
your_team_key: yourTeamData.team_key || '',
|
|
134
|
-
opponent_team_key: opponentTeamData.team_key || ''
|
|
135
|
-
},
|
|
136
|
-
scoreboard: scoreboard
|
|
37
|
+
team_name: rawData.opponent.team_name,
|
|
38
|
+
players: rawData.opponent.players
|
|
39
|
+
}
|
|
137
40
|
};
|
|
138
41
|
}
|
|
139
42
|
/**
|
|
@@ -141,9 +44,29 @@ export class GamesInHandAnalysis extends AnalysisTemplate {
|
|
|
141
44
|
*/
|
|
142
45
|
async analyzeData(data, args) {
|
|
143
46
|
const lookAheadDays = args.look_ahead_days || 7;
|
|
144
|
-
// Calculate games remaining for each team
|
|
47
|
+
// Calculate games remaining for each team from the real NHL schedule
|
|
145
48
|
const yourGamesRemaining = this.calculateGamesRemaining(data.roster.players, lookAheadDays);
|
|
146
49
|
const opponentGamesRemaining = this.calculateGamesRemaining(data.opponent.players || [], lookAheadDays);
|
|
50
|
+
// 🏛️ Rule 3: without the schedule there is no schedule advantage to report.
|
|
51
|
+
// Say so plainly instead of inventing a differential.
|
|
52
|
+
if (yourGamesRemaining === null || opponentGamesRemaining === null) {
|
|
53
|
+
return {
|
|
54
|
+
schedule_available: false,
|
|
55
|
+
schedule_note: NHL_SCHEDULE.getUnavailableReason() ?? 'NHL schedule unavailable',
|
|
56
|
+
your_team: {
|
|
57
|
+
team_name: data.roster.team_name,
|
|
58
|
+
games_remaining: null,
|
|
59
|
+
players_with_games: data.roster.players.filter(p => !p.selected_position.includes('IR'))
|
|
60
|
+
},
|
|
61
|
+
opponent: {
|
|
62
|
+
team_name: data.opponent.team_name || 'Opponent',
|
|
63
|
+
games_remaining: null,
|
|
64
|
+
players_with_games: (data.opponent.players || []).filter((p) => !p.selected_position.includes('IR'))
|
|
65
|
+
},
|
|
66
|
+
advantage: null,
|
|
67
|
+
strategic_recommendation: 'SCHEDULE UNAVAILABLE: could not reach the NHL schedule API, so no games-in-hand edge can be calculated. Retry shortly.'
|
|
68
|
+
};
|
|
69
|
+
}
|
|
147
70
|
const advantage = yourGamesRemaining - opponentGamesRemaining;
|
|
148
71
|
// Generate strategic recommendation based on advantage
|
|
149
72
|
let strategicRecommendation;
|
|
@@ -169,6 +92,8 @@ export class GamesInHandAnalysis extends AnalysisTemplate {
|
|
|
169
92
|
strategicRecommendation = "MASSIVE DISADVANTAGE: Quality over quantity - pick your spots carefully";
|
|
170
93
|
}
|
|
171
94
|
return {
|
|
95
|
+
schedule_available: true,
|
|
96
|
+
schedule_source: `NHL public API (season ${NHL_SCHEDULE.getSeason()})`,
|
|
172
97
|
your_team: {
|
|
173
98
|
team_name: data.roster.team_name,
|
|
174
99
|
games_remaining: yourGamesRemaining,
|
|
@@ -242,16 +167,22 @@ export class GamesInHandAnalysis extends AnalysisTemplate {
|
|
|
242
167
|
};
|
|
243
168
|
}
|
|
244
169
|
/**
|
|
245
|
-
* Helper:
|
|
170
|
+
* Helper: Count real games remaining for a roster over the look-ahead window.
|
|
171
|
+
*
|
|
172
|
+
* Sums each active player's club games from the NHL schedule. A roster of
|
|
173
|
+
* eight MTL skaters and a roster of eight SEA skaters are not the same
|
|
174
|
+
* number of games, which is precisely the edge this tool exists to find.
|
|
175
|
+
*
|
|
176
|
+
* Returns null when the schedule is unavailable — callers must report that
|
|
177
|
+
* rather than fall back to an estimate.
|
|
246
178
|
*/
|
|
247
179
|
calculateGamesRemaining(players, lookAheadDays) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
180
|
+
if (!NHL_SCHEDULE.isAvailable())
|
|
181
|
+
return null;
|
|
182
|
+
const start = NhlScheduleService.today();
|
|
183
|
+
const end = NhlScheduleService.addDays(start, Math.max(0, lookAheadDays - 1));
|
|
251
184
|
const activePlayers = players.filter(p => !p.selected_position.includes('IR') &&
|
|
252
185
|
!p.selected_position.includes('BN'));
|
|
253
|
-
|
|
254
|
-
const weeksInLookAhead = lookAheadDays / 7;
|
|
255
|
-
return Math.round(activePlayers.length * 3.5 * weeksInLookAhead);
|
|
186
|
+
return activePlayers.reduce((total, player) => total + NHL_SCHEDULE.countGamesInRange(player.team, start, end), 0);
|
|
256
187
|
}
|
|
257
188
|
}
|