Skip to content

Commit 6583fca

Browse files
committed
feat(ai): add theme-aware GitHub description generator
1 parent 1563a68 commit 6583fca

4 files changed

Lines changed: 428 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@ Transform your GitHub stats with our diverse theme library:
4444
| Feature | Description |
4545
|---------|-------------|
4646
| AI Roast 🔥 | Get humorous AI-generated roasts of your GitHub profile |
47+
| AI Description ✍️ | Generate a theme-aware summary of your GitHub stats from the app |
4748
| AI Providers | Supports OpenAI and Google Gemini |
48-
| Roast Styles | Choose savage, friendly, or motivational tone |
49+
| Description Tones | Choose professional, funny, or creative tone |
4950

5051
### 🎭 Customization Options
5152

‎ai/description_generator.py‎

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
"""
2+
AI description generator for GitCanvas GitHub stats.
3+
Generates short, theme-aware textual summaries with OpenAI/Gemini and a safe fallback.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import re
9+
from typing import Any
10+
11+
from config.settings import get_settings
12+
from utils.logger import setup_logger
13+
14+
try:
15+
import google.generativeai as genai # type: ignore
16+
17+
_HAS_GENAI = True
18+
except Exception:
19+
genai = None
20+
_HAS_GENAI = False
21+
22+
try:
23+
from openai import OpenAI
24+
except Exception:
25+
OpenAI = None
26+
27+
28+
logger = setup_logger(__name__)
29+
30+
31+
SUPPORTED_TONES = {"professional", "funny", "creative"}
32+
33+
34+
_cfg = get_settings()
35+
OPENAI_API_KEY = _cfg.openai_api_key_value()
36+
GEMINI_API_KEY = _cfg.gemini_api_key_value()
37+
38+
openai_client = None
39+
if OPENAI_API_KEY and OpenAI:
40+
try:
41+
openai_client = OpenAI(api_key=OPENAI_API_KEY)
42+
except Exception as exc:
43+
logger.warning(f"Failed to initialize OpenAI client: {exc}")
44+
45+
if GEMINI_API_KEY and _HAS_GENAI:
46+
try:
47+
genai.configure(api_key=GEMINI_API_KEY)
48+
except Exception as exc:
49+
logger.warning(f"Failed to configure Gemini client: {exc}")
50+
51+
52+
def _normalize_tone(tone: str | None) -> str:
53+
if not tone:
54+
return "professional"
55+
normalized = tone.strip().lower()
56+
if normalized not in SUPPORTED_TONES:
57+
return "professional"
58+
return normalized
59+
60+
61+
def _normalize_top_languages(raw_languages: Any) -> list[str]:
62+
languages: list[str] = []
63+
64+
if not isinstance(raw_languages, list):
65+
return languages
66+
67+
for entry in raw_languages:
68+
if isinstance(entry, dict):
69+
name = entry.get("name")
70+
if isinstance(name, str) and name.strip():
71+
languages.append(name.strip())
72+
elif isinstance(entry, (list, tuple)) and entry:
73+
name = str(entry[0]).strip()
74+
if name:
75+
languages.append(name)
76+
77+
return languages[:5]
78+
79+
80+
def _active_contribution_days(contributions: Any) -> int:
81+
if not isinstance(contributions, list):
82+
return 0
83+
84+
active_days = 0
85+
for item in contributions:
86+
if isinstance(item, dict) and int(item.get("count", 0) or 0) > 0:
87+
active_days += 1
88+
return active_days
89+
90+
91+
def _clean_text(value: str) -> str:
92+
cleaned = value.strip().strip('"').strip("'")
93+
cleaned = re.sub(r"\s+", " ", cleaned).strip()
94+
return cleaned
95+
96+
97+
def create_description_prompt(profile_data: dict[str, Any], theme_name: str, tone: str = "professional") -> str:
98+
"""Create a compact prompt describing profile stats with theme context."""
99+
normalized_tone = _normalize_tone(tone)
100+
101+
username = profile_data.get("username", "developer")
102+
commits = int(profile_data.get("total_commits", 0) or 0)
103+
repos = int(profile_data.get("public_repos", 0) or 0)
104+
followers = int(profile_data.get("followers", 0) or 0)
105+
stars = int(profile_data.get("total_stars", 0) or 0)
106+
107+
languages = _normalize_top_languages(profile_data.get("top_languages", []))
108+
languages_str = ", ".join(languages[:3]) if languages else "multiple languages"
109+
110+
active_days = _active_contribution_days(profile_data.get("contributions", []))
111+
112+
tone_guidelines = {
113+
"professional": "Use a clear, polished and insightful tone.",
114+
"funny": "Use light and friendly humor without being rude.",
115+
"creative": "Use vivid and imaginative wording while staying readable.",
116+
}
117+
118+
return f"""
119+
Write a short GitHub profile description in 3 to 4 sentences.
120+
121+
Username: {username}
122+
Theme selected in app: {theme_name}
123+
Total commits: {commits}
124+
Public repositories: {repos}
125+
Followers: {followers}
126+
Total stars: {stars}
127+
Top languages: {languages_str}
128+
Active contribution days (in loaded dataset): {active_days}
129+
Tone: {normalized_tone}
130+
131+
Rules:
132+
- Mention the selected theme naturally.
133+
- Include at least two concrete stats from above.
134+
- Keep it under 95 words.
135+
- Avoid bullet points and hashtags.
136+
- {tone_guidelines[normalized_tone]}
137+
Return only the description text.
138+
""".strip()
139+
140+
141+
def generate_description_with_openai(profile_data: dict[str, Any], theme_name: str, tone: str = "professional") -> str:
142+
"""Generate description via OpenAI Chat Completions."""
143+
if not openai_client:
144+
raise ValueError("OpenAI is not configured")
145+
146+
prompt = create_description_prompt(profile_data, theme_name, tone)
147+
normalized_tone = _normalize_tone(tone)
148+
149+
temperature = {
150+
"professional": 0.5,
151+
"funny": 0.8,
152+
"creative": 0.9,
153+
}[normalized_tone]
154+
155+
response = openai_client.chat.completions.create(
156+
model="gpt-3.5-turbo",
157+
messages=[
158+
{
159+
"role": "system",
160+
"content": "You summarize GitHub profiles accurately and engagingly.",
161+
},
162+
{
163+
"role": "user",
164+
"content": prompt,
165+
},
166+
],
167+
max_tokens=180,
168+
temperature=temperature,
169+
)
170+
171+
content = response.choices[0].message.content or ""
172+
cleaned = _clean_text(content)
173+
if not cleaned:
174+
raise ValueError("OpenAI returned empty description")
175+
return cleaned
176+
177+
178+
def generate_description_with_gemini(profile_data: dict[str, Any], theme_name: str, tone: str = "professional") -> str:
179+
"""Generate description via Gemini."""
180+
if not GEMINI_API_KEY:
181+
raise ValueError("Gemini API key not configured")
182+
if not _HAS_GENAI:
183+
raise ImportError("google.generativeai is not installed")
184+
185+
prompt = create_description_prompt(profile_data, theme_name, tone)
186+
normalized_tone = _normalize_tone(tone)
187+
188+
temperature = {
189+
"professional": 0.5,
190+
"funny": 0.8,
191+
"creative": 0.9,
192+
}[normalized_tone]
193+
194+
generation_config = genai.types.GenerationConfig(
195+
temperature=temperature,
196+
max_output_tokens=180,
197+
)
198+
199+
last_error: Exception | None = None
200+
for model_name in ("gemini-1.5-flash", "gemini-1.5-flash-latest", "gemini-pro"):
201+
try:
202+
model = genai.GenerativeModel(model_name)
203+
response = model.generate_content(prompt, generation_config=generation_config)
204+
content = getattr(response, "text", "") or ""
205+
cleaned = _clean_text(content)
206+
if cleaned:
207+
return cleaned
208+
except Exception as exc:
209+
last_error = exc
210+
211+
raise ValueError(f"Gemini returned empty description or unsupported model. Last error: {last_error}")
212+
213+
214+
def get_fallback_description(profile_data: dict[str, Any], theme_name: str, tone: str = "professional") -> str:
215+
"""Return a deterministic local fallback description when LLM calls fail."""
216+
normalized_tone = _normalize_tone(tone)
217+
218+
username = profile_data.get("username", "This developer")
219+
commits = int(profile_data.get("total_commits", 0) or 0)
220+
repos = int(profile_data.get("public_repos", 0) or 0)
221+
followers = int(profile_data.get("followers", 0) or 0)
222+
stars = int(profile_data.get("total_stars", 0) or 0)
223+
224+
languages = _normalize_top_languages(profile_data.get("top_languages", []))
225+
primary_lang = languages[0] if languages else "multiple languages"
226+
227+
if normalized_tone == "funny":
228+
return (
229+
f"{username} is running a {theme_name} coding universe where {commits} commits and "
230+
f"{repos} repos keep the plot moving. Their favorite tool seems to be {primary_lang}, "
231+
f"with {stars} stars and {followers} followers cheering from the sidelines."
232+
)
233+
234+
if normalized_tone == "creative":
235+
return (
236+
f"In the {theme_name} palette, {username}'s GitHub story glows through {commits} commits "
237+
f"and {repos} public repositories. {primary_lang} leads the rhythm, while {stars} stars and "
238+
f"{followers} followers reflect the reach of their craft."
239+
)
240+
241+
return (
242+
f"{username} shows consistent development activity with {commits} commits across {repos} public repositories. "
243+
f"Using {primary_lang} as a key language, the profile has gathered {stars} stars and {followers} followers. "
244+
f"The selected {theme_name} theme highlights this contribution pattern clearly."
245+
)
246+
247+
248+
def generate_github_description(
249+
profile_data: dict[str, Any],
250+
theme_name: str,
251+
tone: str = "professional",
252+
) -> dict[str, Any]:
253+
"""
254+
Generate a theme-aware GitHub description with resilient provider fallback.
255+
256+
Returns metadata containing source provider and final description text.
257+
"""
258+
normalized_tone = _normalize_tone(tone)
259+
description_text: str | None = None
260+
source = "fallback"
261+
262+
try:
263+
if openai_client:
264+
try:
265+
description_text = generate_description_with_openai(profile_data, theme_name, normalized_tone)
266+
source = "openai"
267+
except Exception as exc:
268+
logger.warning(f"OpenAI description generation failed: {exc}")
269+
270+
if not description_text and GEMINI_API_KEY and _HAS_GENAI:
271+
try:
272+
description_text = generate_description_with_gemini(profile_data, theme_name, normalized_tone)
273+
source = "gemini"
274+
except Exception as exc:
275+
logger.warning(f"Gemini description generation failed: {exc}")
276+
277+
if not description_text:
278+
description_text = get_fallback_description(profile_data, theme_name, normalized_tone)
279+
source = "fallback"
280+
281+
return {
282+
"success": True,
283+
"description": description_text,
284+
"source": source,
285+
"tone": normalized_tone,
286+
"theme": theme_name,
287+
"username": profile_data.get("username", "developer"),
288+
}
289+
290+
except Exception as exc:
291+
logger.error(f"Unexpected description generation error: {exc}")
292+
return {
293+
"success": False,
294+
"description": get_fallback_description(profile_data, theme_name, normalized_tone),
295+
"source": "fallback",
296+
"tone": normalized_tone,
297+
"theme": theme_name,
298+
"username": profile_data.get("username", "developer"),
299+
"error": str(exc),
300+
}

‎app.py‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from dotenv import load_dotenv
1010
from config.settings import get_settings
1111
from roast_widget_streamlit import render_roast_widget
12+
from ai.description_generator import generate_github_description
1213
from generators import stats_card, lang_card, contrib_card, badge_generator, recent_activity_card, streak_card, repo_card, social_card, trophy_card, sparkline
1314
from utils import github_api
1415
try:
@@ -416,7 +417,12 @@ def show_code_area(code_content, label="Markdown Code"):
416417
def render_embedded_html(html_content: str, *, height: int) -> None:
417418
"""Render inline HTML via iframe to avoid deprecated st.components.v1.html."""
418419
html_b64 = base64.b64encode(html_content.encode("utf-8")).decode("ascii")
419-
st.iframe(f"data:text/html;base64,{html_b64}", height=height, scrolling=False)
420+
iframe_src = f"data:text/html;base64,{html_b64}"
421+
try:
422+
st.iframe(iframe_src, height=height)
423+
except TypeError:
424+
# Compatibility fallback for Streamlit builds where iframe internals changed.
425+
st.components.v1.html(html_content, height=height, scrolling=False)
420426

421427
def render_tab(svg_bytes, endpoint, username, selected_theme, custom_colors, hide_params=None, code_template=None, excluded_languages=None, output_format="Markdown", font_override=None, extra_params=None):
422428
col1, col2 = st.columns([1.5, 1])
@@ -661,6 +667,55 @@ def render_tab(svg_bytes, endpoint, username, selected_theme, custom_colors, hid
661667
_code_label = "HTML Code" if output_format == "HTML" else "Markdown Code"
662668
show_code_area(_spark_code, label=_code_label)
663669

670+
st.markdown("---")
671+
st.subheader("🧠 AI Description")
672+
st.caption("Generate a short, theme-aware summary of this GitHub profile.")
673+
674+
tone_col, button_col = st.columns([1.2, 1])
675+
with tone_col:
676+
description_tone = st.selectbox(
677+
"Tone",
678+
["professional", "funny", "creative"],
679+
index=0,
680+
key="ai_description_tone",
681+
)
682+
683+
with button_col:
684+
if st.button("Generate AI Description", type="primary", use_container_width=True, key="generate_ai_description"):
685+
with st.spinner("Generating AI description..."):
686+
result = generate_github_description(data, selected_theme, tone=description_tone)
687+
st.session_state["ai_description_result"] = {
688+
**result,
689+
"username": username,
690+
"theme": selected_theme,
691+
"tone": description_tone,
692+
}
693+
694+
current_description = st.session_state.get("ai_description_result")
695+
if (
696+
current_description
697+
and current_description.get("username") == username
698+
and current_description.get("theme") == selected_theme
699+
and current_description.get("tone") == description_tone
700+
):
701+
description_text = current_description.get("description", "")
702+
st.markdown("**Generated Description**")
703+
st.write(description_text)
704+
705+
source = current_description.get("source", "fallback")
706+
st.caption(f"Source: {source} • Tone: {description_tone} • Theme: {selected_theme}")
707+
708+
st.download_button(
709+
label="💾 Save Description",
710+
data=description_text,
711+
file_name=f"{username}_{selected_theme}_description.txt",
712+
mime="text/plain",
713+
use_container_width=False,
714+
key="save_ai_description",
715+
)
716+
else:
717+
st.info("Click **Generate AI Description** to create a summary for the current theme.")
718+
664719

665720
with tab2:
666721
st.subheader("Top Languages")

0 commit comments

Comments
 (0)