thenuke02/cs2-analyzer
0
1"""2Two-Tier LLM Client for AI Coaching and Tactical Analysis.3 4Architecture:5 - STANDARD tier (Haiku 4.5): Fast, cheap — 90% of calls6 - DEEP tier (Sonnet 4.5): Complex analysis — 10% of calls7 8Prompt caching: System prompt is >4096 tokens with cache_control9header so repeated calls within 5 minutes pay 90% less on input tokens.10 11Cost tracking: Every API call logs model, tokens, estimated cost,12and cache hit status.13"""14 15import logging16import os17from enum import StrEnum18from typing import Any19 20logger = logging.getLogger(__name__)21 22 23# =============================================================================24# Model Tier Configuration25# =============================================================================26 27 28class ModelTier(StrEnum):29 """Two-tier model selection for cost optimization."""30 31 STANDARD = "claude-haiku-4-5-20251001" # Fast, cheap, 90% of calls32 DEEP = "claude-sonnet-4-5-20250929" # Complex analysis, 10%33 34 35def _get_default_tier() -> ModelTier:36 """Get default tier from environment or fall back to STANDARD."""37 env_tier = os.getenv("LLM_DEFAULT_TIER", "").lower().strip()38 if env_tier == "deep":39 return ModelTier.DEEP40 return ModelTier.STANDARD41 42 43# Pricing per million tokens (USD)44_PRICING = {45 ModelTier.STANDARD: {"input": 1.0, "output": 5.0, "cache_read": 0.1},46 ModelTier.DEEP: {"input": 3.0, "output": 15.0, "cache_read": 0.3},47}48 49 50def _log_usage(tier: ModelTier, usage: Any) -> None:51 """Log token usage and estimated cost after each API call."""52 prices = _PRICING[tier]53 input_tokens = getattr(usage, "input_tokens", 0)54 output_tokens = getattr(usage, "output_tokens", 0)55 cache_read = getattr(usage, "cache_read_input_tokens", 0)56 cache_creation = getattr(usage, "cache_creation_input_tokens", 0)57 58 # Non-cached input tokens = total input - cache_read - cache_creation59 regular_input = max(0, input_tokens - cache_read - cache_creation)60 61 input_cost = regular_input * prices["input"] / 1_000_00062 output_cost = output_tokens * prices["output"] / 1_000_00063 cache_read_cost = cache_read * prices["cache_read"] / 1_000_00064 # Cache creation costs 25% more than regular input65 cache_create_cost = cache_creation * prices["input"] * 1.25 / 1_000_00066 total_cost = input_cost + output_cost + cache_read_cost + cache_create_cost67 68 logger.info(69 "LLM call: model=%s in_tok=%d out_tok=%d cache_read=%d cache_create=%d cost=$%.4f",70 tier.value,71 input_tokens,72 output_tokens,73 cache_read,74 cache_creation,75 total_cost,76 )77 78 79# =============================================================================80# Expanded System Prompt (>4096 tokens for prompt caching)81# =============================================================================82 83CS2_COACHING_SYSTEM_PROMPT = """You are a Tier 1 CS2 Coach with deep expertise in professional Counter-Strike 2.84Analyze player statistics with brutal honesty but constructive feedback.85Focus on actionable improvements backed by specific metrics.86Never hallucinate stats — only reference the exact numbers provided.87 88## Coaching Framework89 90Good CS2 coaching feedback is:911. SPECIFIC — cite exact numbers, rounds, and situations. "Your ADR of 62.392 is below the 75+ threshold for your role" is better than "Your damage is low."932. ACTIONABLE — every critique must include a concrete drill, habit change, or94 practice routine. "Workshop 15 minutes of prefire peek practice on Mirage95 A-ramp daily" beats "Improve your aim."963. GROUNDED IN CONTEXT — a 0.85 rating as entry fragger on a 16-14 loss is97 very different from a 0.85 rating as support on a 16-3 stomp. Always consider98 the match context, role, and scoreline.994. PRIORITIZED — identify the ONE thing that would have the biggest impact if100 fixed. Players can only focus on improving one skill at a time.1015. HONEST — do not soften bad performances. A 0.65 rating is terrible. Say so.102 But also explain the path to improvement so the feedback is constructive.103 104## Performance Benchmarks by Skill Level105 106### HLTV 2.0 Rating107| Level | Rating Range | Description |108|--------------------|-------------|---------------------------------------|109| Elite (Level 10+) | 1.30+ | Star player, carrying the team |110| Advanced (Main) | 1.10-1.29 | Consistent positive impact |111| Intermediate (IM) | 0.90-1.09 | Average — breaking even |112| Developing (Open) | 0.70-0.89 | Below average, needs improvement |113| Struggling | <0.70 | Significantly underperforming |114 115### ADR (Average Damage per Round)116| Level | ADR | What It Means |117|-----------|--------|--------------------------------------------|118| Elite | 90+ | Consistently winning duels and dealing chip |119| Good | 75-89 | Pulling your weight |120| Average | 60-74 | Need to find more engagements |121| Low | <60 | Not engaging enough or losing most duels |122 123### Time to Damage (TTD) — engagement duration in milliseconds124| Level | TTD (ms) | What It Means |125|-----------|----------|------------------------------------------|126| Elite | <200 | Reacting and hitting almost instantly |127| Good | 200-350 | Solid reaction time and crosshair work |128| Average | 350-500 | Room to improve crosshair placement |129| Slow | >500 | Likely getting caught off-guard often |130 131### Crosshair Placement (CP) — angular error in degrees132| Level | Error (deg) | What It Means |133|-----------|-------------|---------------------------------------|134| Elite | <5 | Crosshair is nearly on-target pre-aim |135| Good | 5-15 | Solid crosshair discipline |136| Average | 15-25 | Needs crosshair placement practice |137| Poor | >25 | Significant crosshair placement issue |138 139### KAST% (Kill/Assist/Survive/Trade percentage)140| Level | KAST% | What It Means |141|-----------|--------|--------------------------------------------|142| Elite | 80+ | Contributing meaningfully nearly every round |143| Good | 70-79 | Solid round-by-round contribution |144| Average | 60-69 | Inconsistent round impact |145| Low | <60 | Too many rounds with zero contribution |146 147### Trade Kill Success Rate148| Level | Rate | What It Means |149|-----------|--------|--------------------------------------------|150| Elite | 70+% | Almost always trading fallen teammates |151| Good | 50-69% | Decent trade discipline |152| Average | 30-49% | Need better positioning for refrags |153| Poor | <30% | Major trading discipline problem |154 155## CS2 Role Definitions and Key Metrics156 157### Entry Fragger158Primary responsibility: Be first into the site, create space, get opening kills.159Key metrics: Opening duel win rate (>55% is good), flash-assisted entries, ADR.160Common mistakes: Dry peeking without utility, inconsistent timing, not161communicating what they see before dying.162 163### AWPer164Primary responsibility: Hold angles, get picks, control map areas.165Key metrics: AWP kill efficiency, opening picks, impact rating, deaths with166AWP (losing the $4750 investment).167Common mistakes: Over-peeking when team needs them alive, not repositioning168after a kill, taking bad AWP duels.169 170### Support171Primary responsibility: Flash for entries, throw utility, trade kills, play for team.172Key metrics: Flash assists, KAST%, trade kill success rate, utility damage.173Common mistakes: Throwing utility too early/late, not being in position to174trade, holding utility too long.175 176### Lurker177Primary responsibility: Create pressure away from the team, punish rotations,178gather information.179Key metrics: Clutch win rate, impact kills during rotations, information180gathered (enemy positions revealed).181Common mistakes: Going for hero plays instead of info, being too far from182the team to trade, getting caught in no-mans-land.183 184### IGL (In-Game Leader)185Primary responsibility: Call strats, manage economy, read the opponent, adapt.186Key metrics: Team round-win rate on called strats, economy management grade,187mid-round adaptation success, KAST%.188Common mistakes: Calling too late (team already committed), not adapting after189opponent adjusts, micromanaging instead of letting players play.190 191## Few-Shot Coaching Examples192 193### Example 1: Strong Entry Fragger with Economy Issues194Player: 25K/16D, 1.18 Rating, 82 ADR, 58% opening duel win rate195Round 14: Force-bought deagle+kevlar at $2400 when 3-round loss bonus was196building to $3400. Full save would have guaranteed AK/M4+utility round 15.197Instead, lost the force AND the follow-up eco, turning a 2-round deficit198into a 4-round deficit.199COACHING: "Your fragging impact is strong (1.18 rating, 58% opening duels).200But your force buy in round 14 was the turning point. At $2400 with loss201bonus building, the save gives you a guaranteed full buy worth $5700+ next202round. That one decision likely cost 2 extra rounds. Rule of thumb: never203force when you can full buy in 1 round with loss bonus."204 205### Example 2: Low-Impact Support Player206Player: 11K/18D, 0.68 Rating, 52 ADR, 61% KAST, 2/7 trades207COACHING: "0.68 rating with 52 ADR means you are getting eliminated without208enough impact. As support, your KAST of 61% is too low — you should be209contributing to 70%+ of rounds through flashes, trades, or survival. Most210critically, 2/7 trade attempts (29%) means your teammates are dying and you211are not punishing the enemy. Fix: in your next 5 pugs, focus ONLY on212positioning yourself within 5 seconds of your entry fragger. If they die,213you should see the enemy within 1 second."214 215### Example 3: Inconsistent AWPer216Player: 19K/14D, 1.05 Rating, 71 ADR, 4 AWP kills, 3 opening picks, died217with AWP 6 times218COACHING: "1.05 rating is passable but not what your team needs from the219$4750 investment. You died holding the AWP 6 times — that is $28,500 in220lost equipment across the match. 3 opening picks is decent but 4 total AWP221kills means you got only 1 non-opening kill. After getting your pick, you222need to reposition and look for a second kill, not hold the same angle.223Drill: Play 10 rounds of retake servers focusing on quick-scoping and224repositioning between shots."225 226## Map Callout Reference (Active Duty Pool)227 228### Mirage229T-Side: T Spawn, T Ramp, Underpass, Top Mid, Mid Boxes, Palace, A Ramp,230Tetris, Stairs, A Site, CT Spawn, Jungle, Connector, Window, Short/Catwalk,231B Apartments, B Site, Van, Bench, Market/Kitchen, B Short.232CT-Side: CT Spawn, Ticket Booth, Jungle, Connector, Window Room, A Site,233Stairs, Firebox, Triple, Under Palace, B Site, Van, Short, Market Door,234Bench, Cat, Kitchen.235 236### Inferno237T-Side: T Spawn, T Ramp, Alt Mid, Second Mid, Banana, Car (Banana),238Logs, Half Wall, A Apartments (Apps), A Short/Boiler, A Long, A Site,239Pit, Graveyard, Library, Arch, B Site, First Oranges, Second Oranges,240Dark/Spools, New Box, CT Spawn.241CT-Side: CT Spawn, Arch, Library, Pit, Moto, Site, Balcony, Graveyard,242B Site, Coffins, First Oranges, Dark, New Box, Construction, Banana.243 244### Nuke245T-Side: T Spawn, Outside, T Roof, Lobby, Squeaky, Hut, Main/Mustang,246A Site (Heaven), Hell, Rafters, Mini/Mini-Ramp, Ramp, B Site (Basement),247Vents, Secret, Decon, CT Red Box, Yard.248CT-Side: CT Spawn, Heaven, Hell, Rafters, Trophy, Control Room, Ramp,249B Site, Decon, Secret, Dark, Silo, Garage, Outside.250 251### Ancient252T-Side: T Spawn, T Ramp, Mid, Donut, A Main, A Short, A Link, A Site,253Elbow, Alley, B Ramp, B Main, B Site, B Short, Ruins, Cave.254CT-Side: CT Spawn, CT, A Site, A Short, Elbow, Alley, Temple, B Site,255B Ramp, Tunnel, Waterfall, Cave.256 257### Anubis258T-Side: T Spawn, Mid, T Bridge, Canal, Connector, A Main, A Site,259A Long, Palace, B Main, B Site, B Short, B Long, Walkway, Ruins.260CT-Side: CT Spawn, A Site, Heaven, Boat, Bridge, B Site, B Short,261B Long, Alley, Street, Water.262 263### Dust2264T-Side: T Spawn, T Long, Long Doors, Long Corner, Blue (Pit), A Long,265A Site, A Short/Catwalk, Mid, Mid Doors (Xbox), Lower Tunnels, Upper266Tunnels, B Tunnels, B Site, B Window, B Doors, B Platform.267CT-Side: CT Spawn, CT Mid, A Site, A Short, A Car, A Platform, Goose,268Elevator, B Site, B Window, B Doors, B Back Platform, B Car.269 270## Economy Management Reference271 272### Buy Round Thresholds (per player)273| Round Type | Equipment Value | When to Use |274|---------------|----------------|---------------------------------------|275| Full Buy | $5000-5700+ | AK/M4 + full utility + armor+helmet |276| Force Buy | $2500-4000 | When full save doesn't change outcome |277| Semi-Eco | $1500-2500 | Deagle+armor or SMG+armor |278| Eco/Save | <$1000 | Save for next round full buy |279| Pistol Round | $800 | Default or upgraded pistol + armor |280 281### Key Economy Rules282- Loss bonus resets after a win: $1400, $1900, $2400, $2900, $3400 (max)283- Kill rewards: Rifle $300, SMG $600, Shotgun $900, Knife $1500, AWP $100284- Team money should be tracked collectively — one player force buying when285 4 teammates save breaks the team's economy for 2+ rounds286- The "$4750 rule": never force buy when a full save guarantees rifles next round287- Pistol round wins are worth ~3-4 rounds of advantage (economy snowball)288 289### Economy Decision Grading290- A: Correct buy decision for the situation (team-coordinated full buy/save)291- B: Slightly suboptimal but defensible (semi-force when close to loss bonus max)292- C: Poor decision (scattered buys, no team coordination)293- D: Damaging (force buy that ruins 2+ subsequent rounds)294- F: Critical error (AWP force on eco, dropping weapons to wrong teammates)295 296## Weapon Meta Reference (CS2 2025)297 298### Rifles (Primary Weapons)299- AK-47 ($2700): T-side default, one-shot headshot, spray pattern mastery critical300- M4A4 ($3100): CT-side default, higher fire rate, no one-shot headshot301- M4A1-S ($2900): CT alternative, silenced, tighter spray, 20-round magazine302- AWP ($4750): One-shot body kill, $100 kill reward, huge economy risk if lost303- SG 553 ($3000): Scoped rifle, niche pick for long-range angles304 305### SMGs (Anti-Eco)306- MP9 ($1250): CT anti-eco default, $600 kill reward, high mobility307- MAC-10 ($1050): T anti-eco default, $600 kill reward, very cheap308- MP7 ($1500): Versatile SMG, good against light armor309 310### Pistols311- Desert Eagle ($700): One-shot headshot at range, high skill ceiling312- USP-S (CT default): Silenced, accurate first shot, 12 rounds313- Glock (T default): Burst fire viable, poor armor penetration314- P250 ($300): Budget upgrade, decent armor penetration315 316### Utility ($200-400 each, $1000 total for full set)317- Smoke Grenade ($300): 18-second smoke, blocks vision, extinguishes molotovs318- Flashbang ($200, max 2): Blinds enemies, key for entry support319- HE Grenade ($300): 50-100 damage depending on distance and armor320- Molotov/Incendiary ($400/$600): Area denial, forces position changes321- Decoy ($50): Mimics gunfire, rarely useful outside pistol rounds322 323## Common Tactical Mistakes by Rank324 325### Silver-Gold Nova (Low Rank)326- Not buying armor on pistol round327- Force buying every round328- Using utility randomly (flashing no one, smoking own team)329- Rushing the same site every T round330- Not watching minimap331 332### Master Guardian-Distinguished Master Guardian (Mid Rank)333- Poor trade positioning (too far from teammates)334- Using all utility in first 30 seconds335- Not adapting economy to team money336- Predictable A/B site splits337- Forgetting to check common angles338 339### Supreme-Global Elite / FACEIT Level 7+ (High Rank)340- Dry peeking when utility is available341- Over-rotating on CT side (leaving sites empty)342- Not punishing opponent economy patterns343- Inconsistent communication mid-round344- Ego peeking when playing for time/info345 346### ESEA Main+ / Semi-Pro (Advanced)347- Not varying default setups enough (predictable)348- Economy mistakes in crucial rounds (13-12, 14-14)349- Utility timing off by 1-2 seconds on executes350- Not anti-stratting opponent's known patterns351- Individual play overriding team structure352 353## Round Type Classification354 355### Pistol Rounds (Rounds 1 and 13)356These rounds set the economic trajectory for 3-4 rounds. Winning pistol with357a 3-round win streak is worth approximately $12,000-15,000 in total economic358advantage. Losing pistol means you must survive 1-2 eco rounds before you can359buy. Pistol round performance should be evaluated differently from gun rounds:360- Did the player buy armor? (Failing to buy armor on pistol is almost always wrong)361- Did they use utility effectively? (A well-placed smoke or flash on pistol362 is worth more than on a full buy round because utility is scarce)363- Did they play for trades? (1-for-1 trades on pistol favor the team with364 better economy, which is equal at round start)365 366### Anti-Eco Rounds (Rounds 2-3, 14-15 after winning pistol)367Expected win rate: 85%+. Losing an anti-eco is a critical mistake because:368- You have rifles vs pistols — the firepower advantage is enormous369- The enemy has nothing to lose — they expect to lose this round370- Losing an anti-eco gives the opponent a free rifle round (your dropped weapons)371Anti-eco losses usually come from: poor positioning (getting rushed), not372using utility to slow pushes, taking unnecessary duels at close range where373pistols are lethal.374 375### Force Buy Rounds376A force buy should only happen when: (a) you cannot afford a full buy next377round regardless of saving, or (b) it is match point or a critical round378where one more loss means elimination. The most common economy mistake in379competitive CS2 is the "hope force" — buying rifles without utility when a380full save would guarantee a complete buy next round.381 382### Full Buy Rounds383Expected win rate: 50-55% (CT side advantage). On full buy rounds, individual384mechanical skill matters less and team coordination matters more. If a player385has poor stats on full buy rounds, look at their positioning and utility usage386rather than their aim.387 388## Analysis Decision Tree389 390When analyzing a player's performance, follow this priority order:3911. Check HLTV Rating first — is this player performing above or below expectations?3922. Check ADR — is damage output consistent with their role?3933. Check KAST% — are they contributing every round or disappearing?3944. Check opening duels — are entry kills/deaths appropriate for their role?3955. Check trades — are they trading teammates and getting traded?3966. Check economy — any bad force buys or missed save rounds?3977. Check utility — flash assists, wasted utility, team flashes?3988. Check TTD and CP — mechanical aim issues or just decision-making?399 400Always start with the MOST IMPACTFUL issue. A player with 0.65 rating doesn't401need crosshair placement tips — they need to understand WHY they're not getting402kills (positioning? utility? timing? aim? all of the above?).403 404## Output Format Rules405 4061. Always structure output with markdown headers (##, ###)4072. Bold (**) key stats and numbers for scanability4083. Use bullet points for lists of strengths/weaknesses4094. Keep total response under 200 words for match summaries, 800 for tactical4105. Cite specific round numbers when discussing economy or key moments4116. End every analysis with ONE clear action item the player should focus on4127. Never start with generic praise — lead with the most important finding413"""414 415 416def _build_cached_system(prompt_text: str) -> list[dict[str, Any]]:417 """Wrap a system prompt string in the Anthropic cache_control format."""418 return [419 {420 "type": "text",421 "text": prompt_text,422 "cache_control": {"type": "ephemeral"},423 }424 ]425 426 427# =============================================================================428# Batch summary prompt — trimmed essentials only, no map callouts/weapon meta429# =============================================================================430 431_BATCH_SYSTEM_PROMPT = """You are a Tier 1 CS2 Coach. Analyze each player's statistics with brutal honesty but constructive feedback. Focus on actionable improvements backed by specific metrics. Never hallucinate stats.432 433## Benchmarks434HLTV Rating: Elite 1.30+, Advanced 1.10-1.29, Average 0.90-1.09, Below 0.70-0.89, Struggling <0.70435ADR: Elite 90+, Good 75-89, Average 60-74, Low <60436KAST%: Elite 80+, Good 70-79, Average 60-69, Low <60437Trade Kill Rate: Elite 70%+, Good 50-69%, Average 30-49%, Poor <30%438TTD (engagement speed): Elite <200ms, Good 200-350ms, Average 350-500ms, Slow >500ms439CP (crosshair error): Elite <5 deg, Good 5-15, Average 15-25, Poor >25440 441## Roles442Entry: Opening duel win rate >55% is good. Support: KAST >70%, trade kills, flash assists.443AWPer: Impact picks, don't die with AWP. Lurker: Clutch wins, rotation punishes.444 445## Output Rules446For EACH player, write 3-5 sentences:4471. Lead with the most important finding (best or worst stat)4482. Cite 2-3 specific numbers4493. End with ONE concrete action item450Be harsh on bad performances. A 0.65 rating is terrible — say so.451Format each as markdown. Bold key stats."""452 453 454# =============================================================================455# LLMClient — simple single-call coaching summaries456# =============================================================================457 458 459class LLMClient:460 """461 Client for generating AI coaching summaries.462 463 Uses two-tier model selection:464 - STANDARD (Haiku 4.5): default for match summaries465 - DEEP (Sonnet 4.5): for complex/important analyses466 """467 468 def __init__(469 self,470 api_key: str | None = None,471 tier: ModelTier | None = None,472 timeout: int = 30,473 ):474 """475 Initialize LLM client.476 477 Args:478 api_key: Anthropic API key (defaults to ANTHROPIC_API_KEY env var)479 tier: Model tier to use (defaults to LLM_DEFAULT_TIER env var or STANDARD)480 timeout: Request timeout in seconds481 """482 self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY")483 self.default_tier = tier or _get_default_tier()484 self.timeout = timeout485 486 # Backward compat: expose model string487 self.model = self.default_tier.value488 489 # Lazy import to avoid requiring anthropic if not used490 self._client = None491 492 # Cache the system prompt493 self.system_prompt = CS2_COACHING_SYSTEM_PROMPT494 495 def _get_client(self):496 """Lazy initialization of Anthropic client."""497 if self._client is None:498 try:499 import anthropic500 501 self._client = anthropic.Anthropic(502 api_key=self.api_key,503 timeout=self.timeout,504 )505 except ImportError as e:506 raise ImportError(507 "Anthropic library not installed. Install with: pip install anthropic"508 ) from e509 510 return self._client511 512 def generate_match_summary(513 self,514 player_stats: dict[str, Any],515 match_context: dict[str, Any] | None = None,516 tier: ModelTier | None = None,517 ) -> str:518 """519 Generate AI-powered match summary and coaching insights.520 521 Args:522 player_stats: Player statistics dictionary with:523 - kills, deaths, assists524 - hltv_rating525 - adr (average damage per round)526 - headshot_pct527 - ttd_median_ms (time to damage)528 - cp_median_error_deg (crosshair placement)529 - kast_percentage530 - entry_kills, entry_deaths531 - trade_kill_success, trade_kill_opportunities532 - clutch_wins, clutch_attempts533 match_context: Optional context (map, opponent, team performance)534 tier: Override model tier for this call535 536 Returns:537 Markdown-formatted coaching summary538 539 Raises:540 ValueError: If API key not configured541 Exception: If LLM call fails542 """543 if not self.api_key:544 raise ValueError(545 "ANTHROPIC_API_KEY not configured. Set environment variable or pass api_key to constructor."546 )547 548 use_tier = tier or self.default_tier549 550 # Extract key stats with safe fallbacks551 kills = player_stats.get("kills", 0)552 deaths = player_stats.get("deaths", 0)553 assists = player_stats.get("assists", 0)554 rating = player_stats.get("hltv_rating", 0.0)555 adr = player_stats.get("adr", 0.0)556 hs_pct = player_stats.get("headshot_pct", 0.0)557 kast = player_stats.get("kast_percentage", 0.0)558 559 # Advanced stats560 ttd = player_stats.get("ttd_median_ms", 0)561 cp = player_stats.get("cp_median_error_deg", 0.0)562 entry_kills = player_stats.get("entry_kills", 0)563 entry_deaths = player_stats.get("entry_deaths", 0)564 trade_success = player_stats.get("trade_kill_success", 0)565 trade_opps = player_stats.get("trade_kill_opportunities", 0)566 clutch_wins = player_stats.get("clutch_wins", 0)567 clutch_attempts = player_stats.get("clutch_attempts", 0)568 569 # Validate stats are not all zero (would indicate empty/uninitialized data)570 if kills == 0 and deaths == 0 and rating == 0.0:571 logger.warning("Player stats appear to be uninitialized (all zeros)")572 return (573 "**Error**: Unable to generate summary. Player statistics are not available. "574 "Ensure the demo has been fully analyzed before requesting AI insights."575 )576 577 # Build context string578 context_str = ""579 if match_context:580 map_name = match_context.get("map_name", "")581 rounds = match_context.get("total_rounds", 0)582 result = match_context.get("result", "")583 if map_name:584 context_str = f"Map: {map_name}"585 if rounds:586 context_str += f", Rounds: {rounds}"587 if result:588 context_str += f", Result: {result}"589 590 # Construct user prompt with stats591 user_prompt = f"""Analyze this CS2 match performance:592 593**Core Stats:**594- Kills: {kills}595- Deaths: {deaths}596- Assists: {assists}597- K/D Ratio: {kills / max(deaths, 1):.2f}598- HLTV 2.0 Rating: {rating:.2f}599- ADR: {adr:.1f}600- Headshot %: {hs_pct:.0f}%601- KAST%: {kast:.0f}%602 603**Advanced Metrics:**604- Time to Damage (TTD): {ttd:.0f}ms605- Crosshair Placement: {cp:.1f} error606- Entry Kills: {entry_kills} | Entry Deaths: {entry_deaths}607- Trade Kill Success: {trade_success} / {trade_opps} opportunities608- Clutches Won: {clutch_wins} / {clutch_attempts} attempts609 610{context_str if context_str else ""}611 612Provide a concise analysis with:6131. **3 Strengths**: What they did well (be specific with numbers)6142. **1 Critical Weakness**: The #1 area to improve immediately6153. **Actionable Advice**: One concrete drill or practice focus616 617Format in markdown. Be harsh but fair. Keep it under 200 words."""618 619 try:620 client = self._get_client()621 622 logger.info(623 "Generating LLM summary: tier=%s, %dK/%dD, Rating=%.2f",624 use_tier.value,625 kills,626 deaths,627 rating,628 )629 630 message = client.messages.create(631 model=use_tier.value,632 max_tokens=400,633 system=_build_cached_system(self.system_prompt),634 messages=[635 {"role": "user", "content": user_prompt},636 ],637 )638 639 _log_usage(use_tier, message.usage)640 641 summary = message.content[0].text642 logger.info(f"LLM summary generated successfully ({len(summary)} chars)")643 return summary644 645 except Exception as e:646 logger.error(f"LLM generation failed: {e}")647 # Return fallback summary on error648 return f"""**AI Coaching Unavailable**649 650Unable to generate personalized insights (Error: {type(e).__name__}).651 652**Quick Stats:**653- {kills}K / {deaths}D / {assists}A654- HLTV Rating: {rating:.2f}655- ADR: {adr:.1f}656 657Please check your ANTHROPIC_API_KEY configuration or try again later."""658 659 def generate_batch_summaries(660 self,661 all_player_stats: list[dict[str, Any]],662 match_context: dict[str, Any] | None = None,663 tier: ModelTier | None = None,664 ) -> dict[str, str]:665 """666 Generate coaching summaries for ALL players in a single LLM call.667 668 Args:669 all_player_stats: List of player stat dicts (same format as generate_match_summary)670 match_context: Optional match context (map, rounds, scores)671 tier: Override model tier672 673 Returns:674 Dict mapping player name to markdown summary string675 """676 if not self.api_key:677 raise ValueError(678 "ANTHROPIC_API_KEY not configured. Set environment variable or pass api_key to constructor."679 )680 681 if not all_player_stats:682 return {}683 684 use_tier = tier or self.default_tier685 686 # Build context header687 context_str = ""688 if match_context:689 map_name = match_context.get("map_name", "")690 rounds = match_context.get("total_rounds", 0)691 t1 = match_context.get("team1_score", 0)692 t2 = match_context.get("team2_score", 0)693 context_str = f"Match: {map_name}, Score: {t1}-{t2}, Rounds: {rounds}\n\n"694 695 # Build all players into one prompt696 players_text = ""697 player_names = []698 for ps in all_player_stats:699 name = ps.get("name", "Unknown")700 player_names.append(name)701 kills = ps.get("kills", 0)702 deaths = ps.get("deaths", 0)703 assists = ps.get("assists", 0)704 rating = ps.get("hltv_rating", 0.0)705 adr = ps.get("adr", 0.0)706 hs_pct = ps.get("headshot_pct", 0.0)707 kast = ps.get("kast_percentage", 0.0)708 ttd = ps.get("ttd_median_ms", 0)709 cp = ps.get("cp_median_error_deg", 0.0)710 entry_k = ps.get("entry_kills", 0)711 entry_d = ps.get("entry_deaths", 0)712 trade_s = ps.get("trade_kill_success", 0)713 trade_o = ps.get("trade_kill_opportunities", 0)714 clutch_w = ps.get("clutch_wins", 0)715 clutch_a = ps.get("clutch_attempts", 0)716 717 players_text += f"### {name}\n"718 players_text += (719 f"K/D/A: {kills}/{deaths}/{assists} | "720 f"Rating: {rating:.2f} | ADR: {adr:.1f} | "721 f"HS: {hs_pct:.0f}% | KAST: {kast:.0f}%\n"722 )723 players_text += (724 f"TTD: {ttd:.0f}ms | CP: {cp:.1f}deg | "725 f"Entry: {entry_k}K/{entry_d}D | "726 f"Trades: {trade_s}/{trade_o} | "727 f"Clutches: {clutch_w}/{clutch_a}\n\n"728 )729 730 user_prompt = f"""{context_str}Analyze each player below. Respond with a JSON object where keys are EXACT player names and values are the markdown coaching summary string.731 732{players_text}733Respond ONLY with valid JSON. Example format:734{{"PlayerName": "**1.23 rating** with ...", "Player2": "..."}}"""735 736 try:737 client = self._get_client()738 739 logger.info(740 "Generating batched LLM summaries: tier=%s, players=%d",741 use_tier.value,742 len(all_player_stats),743 )744 745 message = client.messages.create(746 model=use_tier.value,747 max_tokens=2500,748 system=_build_cached_system(_BATCH_SYSTEM_PROMPT),749 messages=[{"role": "user", "content": user_prompt}],750 )751 752 _log_usage(use_tier, message.usage)753 754 raw = message.content[0].text.strip()755 # Strip markdown code fences if present756 if raw.startswith("```"):757 raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:]758 if raw.endswith("```"):759 raw = raw[:-3].strip()760 761 import json762 763 summaries = json.loads(raw)764 765 logger.info(f"Batched summaries generated for {len(summaries)} players")766 return summaries767 768 except Exception as e:769 logger.error(f"Batched LLM generation failed: {e}")770 # Return empty — caller will use per-player fallback771 return {}772 773 774# Singleton instance for reuse775_llm_client_instance: LLMClient | None = None776 777 778def get_llm_client() -> LLMClient:779 """Get or create singleton LLM client instance."""780 global _llm_client_instance781 if _llm_client_instance is None:782 _llm_client_instance = LLMClient()783 return _llm_client_instance784 785 786def generate_match_summary(787 player_stats: dict[str, Any], match_context: dict[str, Any] | None = None788) -> str:789 """790 Convenience function to generate match summary for a single player.791 792 Args:793 player_stats: Player statistics dictionary794 match_context: Optional match context (map, result, etc.)795 796 Returns:797 Markdown-formatted coaching summary798 """799 client = get_llm_client()800 return client.generate_match_summary(player_stats, match_context)801 802 803def generate_batch_summaries(804 all_player_stats: list[dict[str, Any]],805 match_context: dict[str, Any] | None = None,806) -> dict[str, str]:807 """808 Convenience function to generate summaries for all players in one call.809 810 Returns:811 Dict mapping player name to summary string. Empty dict on failure.812 """813 client = get_llm_client()814 return client.generate_batch_summaries(all_player_stats, match_context)815 816 817# =============================================================================818# TacticalAIClient - Claude-powered tactical analysis with tool-use819# =============================================================================820 821 822class TacticalAIClient:823 """824 Claude-powered tactical analysis for CS2 demos.825 826 Uses Claude's tool-use (function calling) to query match data827 and generate comprehensive tactical reports.828 829 Default tier: STANDARD (Haiku 4.5) for most analyses.830 Use DEEP tier for anti-strat generation and game plans.831 """832 833 # Tools Claude can call to query match data834 ANALYSIS_TOOLS = [835 {836 "name": "get_round_data",837 "description": "Get detailed data for a specific round including kills, economy, utility usage",838 "input_schema": {839 "type": "object",840 "properties": {841 "round_number": {842 "type": "integer",843 "description": "Round number (1-30+)",844 },845 },846 "required": ["round_number"],847 },848 },849 {850 "name": "get_player_stats",851 "description": "Get a player's full statistics for the match",852 "input_schema": {853 "type": "object",854 "properties": {855 "player_name": {"type": "string", "description": "Player name"},856 },857 "required": ["player_name"],858 },859 },860 {861 "name": "get_economy_timeline",862 "description": "Get team economy state across all rounds",863 "input_schema": {864 "type": "object",865 "properties": {866 "team": {867 "type": "string",868 "enum": ["CT", "T"],869 "description": "Team to get economy for",870 },871 },872 "required": ["team"],873 },874 },875 {876 "name": "get_kills_by_round",877 "description": "Get all kills in a specific round with positions and weapons",878 "input_schema": {879 "type": "object",880 "properties": {881 "round_number": {882 "type": "integer",883 "description": "Round number",884 },885 },886 "required": ["round_number"],887 },888 },889 {890 "name": "get_utility_usage",891 "description": "Get all utility (grenade) usage for a round or entire match",892 "input_schema": {893 "type": "object",894 "properties": {895 "round_number": {896 "type": "integer",897 "description": "Round number (omit for all rounds)",898 },899 },900 },901 },902 ]903 904 def __init__(905 self,906 api_key: str | None = None,907 tier: ModelTier | None = None,908 ):909 """910 Initialize TacticalAIClient.911 912 Args:913 api_key: Anthropic API key (defaults to ANTHROPIC_API_KEY env var)914 tier: Model tier (defaults to LLM_DEFAULT_TIER env var or STANDARD)915 """916 self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY")917 self.default_tier = tier or _get_default_tier()918 self.model = self.default_tier.value919 self._client = None920 921 def _get_client(self):922 """Lazy initialization of Anthropic client."""923 if self._client is None:924 try:925 import anthropic926 927 self._client = anthropic.Anthropic(928 api_key=self.api_key,929 timeout=60, # Longer timeout for complex analysis930 )931 except ImportError as e:932 raise ImportError(933 "Anthropic library not installed. Install with: pip install anthropic"934 ) from e935 return self._client936 937 def _execute_tool(self, tool_name: str, tool_input: dict, match_data: dict) -> str:938 """Execute a tool call with real match data.939 940 Returns compressed summaries instead of raw dicts to reduce token usage.941 Full player dicts can be 4-8K tokens; compressed versions are ~300-500 tokens.942 """943 import json944 945 if tool_name == "get_round_data":946 round_num = tool_input.get("round_number", 1)947 timeline = match_data.get("round_timeline", [])948 for r in timeline:949 if r.get("round_num") == round_num:950 return json.dumps(self._compress_round(r), default=str)951 return json.dumps({"error": f"Round {round_num} not found"})952 953 elif tool_name == "get_player_stats":954 name = tool_input.get("player_name", "").lower()955 players = match_data.get("players", {})956 for _sid, player in players.items():957 if player.get("name", "").lower() == name:958 return json.dumps(self._compress_player(player), default=str)959 return json.dumps({"error": f"Player '{name}' not found"})960 961 elif tool_name == "get_economy_timeline":962 team = tool_input.get("team", "CT")963 timeline = match_data.get("round_timeline", [])964 economy = []965 team_key = "ct" if team == "CT" else "t"966 for r in timeline:967 rn = r.get("round_num", 0)968 econ = r.get("economy") or {}969 team_econ = econ.get(team_key) or {}970 economy.append(971 {972 "round": rn,973 "equipment_value": team_econ.get("equipment", 0),974 "round_type": team_econ.get("buy_type", "unknown"),975 "loss_bonus": team_econ.get("loss_bonus", 0),976 "decision_grade": team_econ.get("decision_grade", ""),977 }978 )979 return json.dumps(economy, default=str)980 981 elif tool_name == "get_kills_by_round":982 round_num = tool_input.get("round_number", 1)983 timeline = match_data.get("round_timeline", [])984 for r in timeline:985 if r.get("round_num") == round_num:986 kills = r.get("kills", [])987 return json.dumps([self._compress_kill(k) for k in kills], default=str)988 return json.dumps({"error": f"Round {round_num} not found"})989 990 elif tool_name == "get_utility_usage":991 round_num = tool_input.get("round_number")992 timeline = match_data.get("round_timeline", [])993 if round_num:994 for r in timeline:995 if r.get("round_num") == round_num:996 utils = r.get("utility", [])997 return json.dumps([self._compress_utility(u) for u in utils], default=str)998 return json.dumps({"error": f"Round {round_num} not found"})999 # Summary per round instead of dumping every event1000 summary = {}1001 for r in timeline:1002 rn = r.get("round_num", 0)1003 utils = r.get("utility", [])1004 if utils:1005 by_type: dict[str, int] = {}1006 for u in utils:1007 t = u.get("type", "unknown")1008 by_type[t] = by_type.get(t, 0) + 11009 summary[f"R{rn}"] = by_type1010 return json.dumps(summary, default=str)1011 1012 return json.dumps({"error": f"Unknown tool: {tool_name}"})1013 1014 @staticmethod1015 def _compress_player(player: dict) -> dict:1016 """Compress a full player dict (~4-8K tokens) to essentials (~300 tokens)."""1017 stats = player.get("stats", {})1018 rating = player.get("rating", {})1019 adv = player.get("advanced", {})1020 entry = player.get("entry", {})1021 trades = player.get("trades", {})1022 clutches = player.get("clutches", {})1023 util = player.get("utility", {})1024 duels = player.get("duels", {})1025 1026 return {1027 "name": player.get("name"),1028 "team": player.get("team"),1029 "kills": stats.get("kills", 0),1030 "deaths": stats.get("deaths", 0),1031 "assists": stats.get("assists", 0),1032 "adr": stats.get("adr", 0),1033 "hs_pct": stats.get("headshot_pct", 0),1034 "hltv_rating": rating.get("hltv_rating", 0),1035 "kast": rating.get("kast_percentage", 0),1036 "aim_rating": rating.get("aim_rating", 0),1037 "utility_rating": rating.get("utility_rating", 0),1038 "impact_rating": rating.get("impact_rating", 0),1039 "ttd_ms": adv.get("ttd_median_ms"),1040 "cp_deg": adv.get("cp_median_error_deg"),1041 "entry_kills": entry.get("entry_kills", 0),1042 "entry_deaths": entry.get("entry_deaths", 0),1043 "entry_success_pct": entry.get("entry_success_pct", 0),1044 "trade_kill_success": trades.get("trade_kill_success", 0),1045 "trade_kill_opps": trades.get("trade_kill_opportunities", 0),1046 "trade_kill_pct": trades.get("trade_kill_success_pct", 0),1047 "untraded_deaths": trades.get("untraded_deaths", 0),1048 "clutch_wins": clutches.get("clutch_wins", 0),1049 "clutch_total": clutches.get("total_situations", 0),1050 "opening_kills": duels.get("opening_kills", 0),1051 "opening_deaths": duels.get("opening_deaths", 0),1052 "flash_assists": util.get("flash_assists", 0),1053 "enemies_flashed": util.get("enemies_flashed", 0),1054 "he_damage": util.get("he_damage", 0),1055 "molotov_damage": util.get("molotov_damage", 0),1056 "util_thrown": (1057 util.get("flashbangs_thrown", 0)1058 + util.get("smokes_thrown", 0)1059 + util.get("he_thrown", 0)1060 + util.get("molotovs_thrown", 0)1061 ),1062 "multi_kills": {1063 "2k": stats.get("2k", 0),1064 "3k": stats.get("3k", 0),1065 "4k": stats.get("4k", 0),1066 "5k": stats.get("5k", 0),1067 },1068 }1069 1070 @staticmethod1071 def _compress_round(r: dict) -> dict:1072 """Compress a full round dict to essentials. Drops coordinates/positions."""1073 kills = r.get("kills", [])1074 compressed_kills = [1075 {1076 "killer": k.get("killer"),1077 "victim": k.get("victim"),1078 "weapon": k.get("weapon"),1079 "headshot": k.get("headshot"),1080 "killer_team": k.get("killer_team"),1081 }1082 for k in kills1083 ]1084 econ = r.get("economy") or {}1085 ct_econ = econ.get("ct") or {}1086 t_econ = econ.get("t") or {}1087 1088 return {1089 "round_num": r.get("round_num"),1090 "winner": r.get("winner"),1091 "win_reason": r.get("win_reason"),1092 "round_type": r.get("round_type"),1093 "first_kill": r.get("first_kill"),1094 "first_death": r.get("first_death"),1095 "ct_kills": r.get("ct_kills", 0),1096 "t_kills": r.get("t_kills", 0),1097 "kills": compressed_kills,1098 "economy": {1099 "ct_buy": ct_econ.get("buy_type", "unknown"),1100 "ct_equip": ct_econ.get("equipment", 0),1101 "t_buy": t_econ.get("buy_type", "unknown"),1102 "t_equip": t_econ.get("equipment", 0),1103 },1104 "clutches": r.get("clutches", []),1105 }1106 1107 @staticmethod1108 def _compress_kill(k: dict) -> dict:1109 """Compress a kill event — drop coordinates, keep tactical info."""1110 return {1111 "killer": k.get("killer"),1112 "victim": k.get("victim"),1113 "weapon": k.get("weapon"),1114 "headshot": k.get("headshot"),1115 "killer_team": k.get("killer_team"),1116 "is_trade": k.get("is_trade"),1117 "is_first_kill": k.get("is_first_kill"),1118 }1119 1120 @staticmethod1121 def _compress_utility(u: dict) -> dict:1122 """Compress a utility event — drop coordinates, keep type and player."""1123 return {1124 "type": u.get("type"),1125 "player": u.get("player"),1126 "team": u.get("team"),1127 }1128 1129 def analyze(1130 self,1131 match_data: dict,1132 analysis_type: str = "overview",1133 focus: str | None = None,1134 system_prompt: str | None = None,1135 tier: ModelTier | None = None,1136 ) -> str:1137 """1138 Generate tactical analysis using Claude with tool-use.1139 1140 Args:1141 match_data: Parsed match data from CachedAnalyzer1142 analysis_type: Type of analysis (overview, strat-steal, self-review, scout)1143 focus: Optional focus (specific round, player, or side)1144 system_prompt: Optional custom system prompt1145 tier: Override model tier for this call1146 1147 Returns:1148 Markdown-formatted tactical report1149 """1150 1151 if not self.api_key:1152 raise ValueError(1153 "ANTHROPIC_API_KEY not configured. "1154 "Set environment variable or pass api_key to constructor."1155 )1156 1157 use_tier = tier or self.default_tier1158 1159 # Import system prompts1160 from opensight.ai.tactical import get_system_prompt1161 1162 # Get appropriate system prompt1163 if system_prompt is None:1164 system_prompt = get_system_prompt(analysis_type)1165 1166 # Pre-process match data into structured summary for better LLM context1167 from opensight.ai.data_prep import preprocess_match, to_llm_prompt1168 1169 match_summary = preprocess_match(match_data)1170 1171 # Map analysis_type to data_prep focus1172 focus_map = {1173 "overview": "coaching",1174 "strat-steal": "scouting",1175 "self-review": "coaching",1176 "scout": "scouting",1177 "quick": "coaching",1178 }1179 prep_focus = focus_map.get(analysis_type, "coaching")1180 structured_data = to_llm_prompt(match_summary, focus=prep_focus)1181 1182 map_name = match_summary.map_name1183 total_rounds = match_summary.total_rounds1184 1185 # Build user prompt with preprocessed data + tool instructions1186 focus_str = f" Focus on: {focus}." if focus else ""1187 user_prompt = f"""Analyze this CS2 match:1188 1189{structured_data}1190 1191**Analysis Type:** {analysis_type}1192{focus_str}1193 1194Use the tools available to query specific round data, player stats, and economy1195timeline for deeper investigation. The structured data above gives you the overview —1196use tools to drill into specific rounds or players that stand out.1197Generate a comprehensive tactical report in markdown format."""1198 1199 try:1200 client = self._get_client()