CoolFace
Apppublic

thenuke02/cs2-analyzer

sourceHugging Faceupdated 7mo agoView on Hugging Face
0likes
llm_client.py1301 linesDownload Raw Back to ai
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()

Showing the first 1,200 of 1301 lines. Download the file for the rest.