|
4 | 4 |
|
5 | 5 | from asyncio import run |
6 | 6 | from datetime import datetime |
7 | | -from typing import Dict |
| 7 | +from typing import Dict, List, Optional |
8 | 8 | from urllib.parse import quote |
9 | 9 |
|
10 | 10 | from humanize import intword, naturalsize, intcomma |
|
23 | 23 | ) |
24 | 24 |
|
25 | 25 |
|
| 26 | +def find_category(categories: List[Dict], name: str) -> Optional[Dict]: |
| 27 | + """ |
| 28 | + Find a WakaTime category entry (e.g. "AI Coding") by name in a stats response's `categories` list. |
| 29 | +
|
| 30 | + :param categories: List of category dictionaries from a WakaTime stats response. |
| 31 | + :param name: Category name to look for. |
| 32 | + :returns: The matching category dictionary, or None if not found. |
| 33 | + """ |
| 34 | + return next((category for category in categories if category["name"] == name), None) |
| 35 | + |
| 36 | + |
| 37 | +def make_ai_coding_insights(ai_written_percent: float, prompt_length_avg: float, prompts_per_session: float, manual_touch_percent: float) -> str: |
| 38 | + """ |
| 39 | + Deduce a few human-readable insight lines from the raw AI coding numbers: |
| 40 | + how AI-reliant the week was, prompting style (length), session style (one-shot vs. follow-ups), |
| 41 | + and how much of the changed code was still touched by hand (a proxy for manual review). |
| 42 | + All tiers are computed purely from ratios already present in the WakaTime response, no extra API calls. |
| 43 | +
|
| 44 | + :param ai_written_percent: Share of added lines written by AI (0-100). |
| 45 | + :param prompt_length_avg: Average prompt length in characters. |
| 46 | + :param prompts_per_session: Average number of prompts per AI session. |
| 47 | + :param manual_touch_percent: Share of all changed lines (additions + deletions) that were human-made (0-100). |
| 48 | + :returns: String representation of the insight lines. |
| 49 | + """ |
| 50 | + # Thresholds are heuristic tiers over continuous ratios, not WakaTime-defined categories. |
| 51 | + reliance_label = ( |
| 52 | + FM.t("AI Reliance: AI-Driven") |
| 53 | + if ai_written_percent >= 66 |
| 54 | + else FM.t("AI Reliance: Balanced") if ai_written_percent >= 33 else FM.t("AI Reliance: Hands-On") |
| 55 | + ) |
| 56 | + prompt_style_label = ( |
| 57 | + FM.t("Prompt Style: Verbose") |
| 58 | + if prompt_length_avg > 1500 |
| 59 | + else FM.t("Prompt Style: Detailed") if prompt_length_avg >= 500 else FM.t("Prompt Style: Concise") |
| 60 | + ) |
| 61 | + session_style_label = FM.t("Session Style: Iterative") if prompts_per_session > 1.5 else FM.t("Session Style: One-Shot") |
| 62 | + review_label = FM.t("Review Style: Hands-On Reviewer") if manual_touch_percent >= 50 else FM.t("Review Style: High AI Trust") |
| 63 | + |
| 64 | + insights = f"🔎 {FM.t('AI Coding Insights')}:\n" |
| 65 | + insights += f"{FM.t('AI Reliance Detail') % (reliance_label, round(ai_written_percent, 2))}\n" |
| 66 | + insights += f"{FM.t('Prompt Style Detail') % (prompt_style_label, intcomma(round(prompt_length_avg)))}\n" |
| 67 | + insights += f"{FM.t('Session Style Detail') % (session_style_label, round(prompts_per_session, 1))}\n" |
| 68 | + insights += f"{FM.t('Review Style Detail') % (review_label, round(manual_touch_percent, 2))}\n" |
| 69 | + return insights |
| 70 | + |
| 71 | + |
| 72 | +def make_ai_coding_stats(data: Dict) -> str: |
| 73 | + """ |
| 74 | + Build the weekly AI coding stats block: AI coding time, AI vs human written lines, |
| 75 | + token usage, estimated AI cost, sessions/prompts, per-model breakdown and deduced insights. |
| 76 | + Renders a "no activity" fallback (instead of hiding the section) if the account has no AI coding data this week. |
| 77 | +
|
| 78 | + :param data: WakaTime weekly stats response (`waka_latest`). |
| 79 | + :returns: String representation of the AI coding stats. |
| 80 | + """ |
| 81 | + ai_category = find_category(data["data"].get("categories", []), "AI Coding") |
| 82 | + ai_sessions = data["data"].get("ai_sessions", 0) |
| 83 | + |
| 84 | + stats = f"🤖 **{FM.t('AI Coding This Week')}** \n\n```text\n" |
| 85 | + |
| 86 | + if ai_category is None or not ai_sessions: |
| 87 | + stats += f"{FM.t('No AI Coding Activity Tracked This Week')}\n\n" |
| 88 | + return f"{stats[:-1]}```\n\n" |
| 89 | + |
| 90 | + ai_additions = data["data"].get("ai_additions", 0) |
| 91 | + ai_deletions = data["data"].get("ai_deletions", 0) |
| 92 | + human_additions = data["data"].get("human_additions", 0) |
| 93 | + human_deletions = data["data"].get("human_deletions", 0) |
| 94 | + ai_input_tokens = data["data"].get("ai_input_tokens", 0) |
| 95 | + ai_output_tokens = data["data"].get("ai_output_tokens", 0) |
| 96 | + ai_cost = data["data"].get("ai_model_total_cost", 0) |
| 97 | + ai_prompts = data["data"].get("ai_prompt_events_total", 0) |
| 98 | + prompt_length_avg = data["data"].get("ai_prompt_length_avg", 0) |
| 99 | + prompts_per_session = data["data"].get("ai_prompt_events_avg_per_session", 0) |
| 100 | + |
| 101 | + total_additions = ai_additions + human_additions |
| 102 | + ai_written_percent = (ai_additions / total_additions * 100) if total_additions else 0 |
| 103 | + |
| 104 | + total_changes = ai_additions + ai_deletions + human_additions + human_deletions |
| 105 | + manual_touch_percent = ((human_additions + human_deletions) / total_changes * 100) if total_changes else 0 |
| 106 | + |
| 107 | + stats += f"⏱ {FM.t('AI Coding Time')}: {ai_category['text']} ({ai_category['percent']}%)\n\n" |
| 108 | + stats += f"✍️ {FM.t('AI vs Human Lines') % (intcomma(ai_additions), intcomma(human_additions), round(ai_written_percent, 2))}\n\n" |
| 109 | + stats += f"🔤 {FM.t('AI Token Usage') % (intcomma(ai_input_tokens), intcomma(ai_output_tokens))}\n\n" |
| 110 | + stats += f"💵 {FM.t('Estimated AI Cost') % f'{ai_cost:.2f}'}\n\n" |
| 111 | + stats += f"🧠 {FM.t('AI Sessions and Prompts') % (ai_sessions, ai_prompts)}\n\n" |
| 112 | + |
| 113 | + ai_model_breakdown = data["data"].get("ai_model_breakdown", []) |
| 114 | + if ai_model_breakdown: |
| 115 | + total_lines = sum(model["lines"] for model in ai_model_breakdown) or 1 |
| 116 | + names = [model["name"] for model in ai_model_breakdown] |
| 117 | + texts = [f"{intcomma(model['lines'])} lines" for model in ai_model_breakdown] |
| 118 | + percents = [round(model["lines"] / total_lines * 100, 2) for model in ai_model_breakdown] |
| 119 | + stats += f"{make_list(names=names, texts=texts, percents=percents)}\n\n" |
| 120 | + |
| 121 | + stats += f"{make_ai_coding_insights(ai_written_percent, prompt_length_avg, prompts_per_session, manual_touch_percent)}\n" |
| 122 | + |
| 123 | + return f"{stats[:-1]}```\n\n" |
| 124 | + |
| 125 | + |
26 | 126 | async def get_waka_time_stats(repositories: Dict, commit_dates: Dict) -> str: |
27 | 127 | """ |
28 | 128 | Collects user info from wakatime. |
@@ -74,6 +174,10 @@ async def get_waka_time_stats(repositories: Dict, commit_dates: Dict) -> str: |
74 | 174 |
|
75 | 175 | stats = f"{stats[:-1]}```\n\n" |
76 | 176 |
|
| 177 | + if EM.SHOW_AI_CODING: |
| 178 | + DBM.i("Adding AI coding stats...") |
| 179 | + stats += make_ai_coding_stats(data) |
| 180 | + |
77 | 181 | DBM.g("WakaTime stats added!") |
78 | 182 | return stats |
79 | 183 |
|
@@ -193,13 +297,28 @@ async def get_stats() -> str: |
193 | 297 | yearly_data, commit_data = dict(), dict() |
194 | 298 | DBM.w("User yearly data not needed, skipped.") |
195 | 299 |
|
196 | | - if EM.SHOW_TOTAL_CODE_TIME: |
| 300 | + if EM.SHOW_TOTAL_CODE_TIME or EM.SHOW_AI_CODE_TIME: |
197 | 301 | DBM.i("Adding total code time info...") |
198 | 302 | data = await DM.get_remote_json("waka_all") |
199 | 303 | if data is None: |
200 | 304 | DBM.p("WakaTime data unavailable!") |
201 | 305 | else: |
202 | | - stats += f"}-{quote(str(data['data']['text']))}-blue?style={quote(EM.BADGE_STYLE)})\n\n" |
| 306 | + if EM.SHOW_TOTAL_CODE_TIME: |
| 307 | + stats += ( |
| 308 | + f"}-" |
| 309 | + f"{quote(str(data['data']['human_readable_total']))}-blue?style={quote(EM.BADGE_STYLE)})\n\n" |
| 310 | + ) |
| 311 | + |
| 312 | + if EM.SHOW_AI_CODE_TIME: |
| 313 | + DBM.i("Adding AI code time info...") |
| 314 | + ai_category = find_category(data["data"].get("categories", []), "AI Coding") |
| 315 | + if ai_category is None: |
| 316 | + DBM.w("No all-time AI coding data available, skipping AI Code Time badge.") |
| 317 | + else: |
| 318 | + stats += ( |
| 319 | + f"}-" |
| 320 | + f"{quote(str(ai_category['text']))}-blue?style={quote(EM.BADGE_STYLE)})\n\n" |
| 321 | + ) |
203 | 322 |
|
204 | 323 | if EM.SHOW_PROFILE_VIEWS: |
205 | 324 | if EM.DEBUG_RUN or GHM.REMOTE is None: |
|
0 commit comments