gitradar 0.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. demo/api/index.py +14 -0
  2. demo/gitradar/__init__.py +22 -0
  3. demo/gitradar/cli.py +214 -0
  4. demo/gitradar/config.py +117 -0
  5. demo/gitradar/models.py +67 -0
  6. demo/gitradar/prompts/__init__.py +18 -0
  7. demo/gitradar/services/github.py +179 -0
  8. demo/gitradar/services/llm.py +342 -0
  9. demo/gitradar/utils/ui.py +196 -0
  10. demo/gitradar/web/__init__.py +7 -0
  11. demo/gitradar/web/app.py +173 -0
  12. examples/python_sdk_usage.py +48 -0
  13. gitradar/__init__.py +22 -0
  14. gitradar/cli.py +214 -0
  15. gitradar/config.py +117 -0
  16. gitradar/models.py +67 -0
  17. gitradar/prompts/__init__.py +18 -0
  18. gitradar/prompts/gap_analysis_system.j2 +36 -0
  19. gitradar/prompts/gap_analysis_user.j2 +21 -0
  20. gitradar/prompts/query_expansion_system.j2 +19 -0
  21. gitradar/prompts/query_expansion_user.j2 +1 -0
  22. gitradar/prompts/relevance_evaluation_system.j2 +22 -0
  23. gitradar/prompts/relevance_evaluation_user.j2 +14 -0
  24. gitradar/services/github.py +179 -0
  25. gitradar/services/llm.py +342 -0
  26. gitradar/utils/ui.py +196 -0
  27. gitradar/web/__init__.py +7 -0
  28. gitradar/web/app.py +173 -0
  29. gitradar/web/static/apple-touch-icon.png +0 -0
  30. gitradar/web/static/css/style.css +1430 -0
  31. gitradar/web/static/favicon-96x96.png +0 -0
  32. gitradar/web/static/favicon.ico +0 -0
  33. gitradar/web/static/favicon.svg +1 -0
  34. gitradar/web/static/js/app.js +1206 -0
  35. gitradar/web/static/site.webmanifest +21 -0
  36. gitradar/web/static/web-app-manifest-192x192.png +0 -0
  37. gitradar/web/static/web-app-manifest-512x512.png +0 -0
  38. gitradar/web/templates/index.html +521 -0
  39. gitradar-0.1.5.dist-info/METADATA +251 -0
  40. gitradar-0.1.5.dist-info/RECORD +51 -0
  41. gitradar-0.1.5.dist-info/WHEEL +5 -0
  42. gitradar-0.1.5.dist-info/entry_points.txt +2 -0
  43. gitradar-0.1.5.dist-info/licenses/LICENSE +21 -0
  44. gitradar-0.1.5.dist-info/top_level.txt +4 -0
  45. tests/conftest.py +27 -0
  46. tests/test_cli.py +29 -0
  47. tests/test_llm.py +163 -0
  48. tests/test_models.py +90 -0
  49. tests/test_prompts.py +58 -0
  50. tests/test_relevance.py +75 -0
  51. tests/test_web.py +81 -0
@@ -0,0 +1,342 @@
1
+ import json
2
+ import os
3
+ import re
4
+ from typing import List, Optional
5
+ from openai import OpenAI, AuthenticationError, APIError, NotFoundError, BadRequestError
6
+ from gitradar.config import settings
7
+ from gitradar.models import ExpandedQueries, GapAnalysisReport, RepositoryInfo
8
+ from gitradar.prompts import render_prompt
9
+
10
+
11
+ def extract_json(content: str) -> dict:
12
+ """Extract and parse JSON object from LLM response text, handling markdown blocks or thought tags."""
13
+ if not content:
14
+ raise ValueError("Empty LLM response content received.")
15
+
16
+ cleaned = content.strip()
17
+
18
+ # Remove <think>...</think> block if present
19
+ cleaned = re.sub(r"<think>.*?</think>", "", cleaned, flags=re.DOTALL).strip()
20
+
21
+ # Remove markdown code blocks if wrapped in ```json ... ``` or ``` ... ```
22
+ if "```" in cleaned:
23
+ cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned, flags=re.IGNORECASE)
24
+ cleaned = re.sub(r"\s*```$", "", cleaned)
25
+ cleaned = cleaned.strip()
26
+
27
+ # Find outer-most JSON bounds: first '{' and last '}'
28
+ start = cleaned.find("{")
29
+ end = cleaned.rfind("}")
30
+ if start != -1 and end != -1 and end > start:
31
+ cleaned = cleaned[start : end + 1].strip()
32
+
33
+ try:
34
+ return json.loads(cleaned)
35
+ except json.JSONDecodeError:
36
+ # Sanitize trailing commas: e.g. ", }" -> "}" or ", ]" -> "]"
37
+ sanitized = re.sub(r",\s*([\}\]])", r"\1", cleaned)
38
+ return json.loads(sanitized)
39
+
40
+
41
+ class LLMService:
42
+ """Service to interact with OpenAI-compatible API endpoints for query expansion and gap analysis."""
43
+
44
+ def __init__(
45
+ self,
46
+ api_key: Optional[str] = None,
47
+ base_url: Optional[str] = None,
48
+ model: Optional[str] = None,
49
+ language: Optional[str] = None,
50
+ ):
51
+ if api_key is not None:
52
+ self.api_key = api_key.strip().strip("'\"")
53
+ else:
54
+ raw_key = (
55
+ settings.openai_api_key
56
+ or os.environ.get("OPENAI_API_KEY")
57
+ or settings.groq_api_key
58
+ or os.environ.get("GROQ_API_KEY")
59
+ or ""
60
+ )
61
+ self.api_key = raw_key.strip().strip("'\"")
62
+
63
+ if base_url is not None:
64
+ raw_base_url = base_url
65
+ else:
66
+ raw_base_url = (
67
+ settings.openai_base_url
68
+ or os.environ.get("OPENAI_BASE_URL")
69
+ or os.environ.get("OPENAI_API_BASE")
70
+ )
71
+
72
+ if raw_base_url:
73
+ self.base_url = raw_base_url.strip().strip("'\"").rstrip("/")
74
+ elif self.api_key.startswith("gsk_"):
75
+ # Auto-detect Groq OpenAI-compatible endpoint for Groq keys
76
+ self.base_url = "https://api.groq.com/openai/v1"
77
+ elif api_key is None and not self.api_key and settings.groq_api_key:
78
+ # Fallback to Groq if only groq is configured
79
+ self.api_key = settings.groq_api_key.strip().strip("'\"")
80
+ self.base_url = "https://api.groq.com/openai/v1"
81
+ else:
82
+ self.base_url = None
83
+
84
+ # Local endpoints (Ollama, LM Studio, vLLM) may run without API keys
85
+ if self.base_url and any(h in self.base_url for h in ["localhost", "127.0.0.1", "0.0.0.0"]):
86
+ if not self.api_key:
87
+ self.api_key = "ollama"
88
+
89
+ raw_model = model or settings.default_model or "gpt-4o-mini"
90
+ if raw_model.startswith("groq/"):
91
+ raw_model = raw_model.replace("groq/", "", 1)
92
+
93
+ # If routed to Groq and model is OpenAI default, default to Groq's high-capability model
94
+ if self.base_url and "groq.com" in self.base_url and raw_model in ("gpt-4o-mini", "gpt-4o"):
95
+ raw_model = "llama-3.3-70b-versatile"
96
+
97
+ self.model = raw_model
98
+ self.language = language or settings.default_language
99
+ self._client: Optional[OpenAI] = None
100
+
101
+ @property
102
+ def client(self) -> OpenAI:
103
+ if self._client is None:
104
+ self._ensure_api_key()
105
+ effective_key = self.api_key or "no-key-required"
106
+ self._client = OpenAI(
107
+ api_key=effective_key,
108
+ base_url=self.base_url,
109
+ timeout=60.0,
110
+ )
111
+ return self._client
112
+
113
+ def _ensure_api_key(self):
114
+ # Local endpoints (Ollama, LM Studio, vLLM) may run without API keys
115
+ if self.base_url and any(h in self.base_url for h in ["localhost", "127.0.0.1", "0.0.0.0"]):
116
+ if not self.api_key:
117
+ self.api_key = "ollama"
118
+ return
119
+
120
+ if not self.api_key or self.api_key.strip() in ("", "sk-...", "gsk_...", "gsk_your_groq_api_key_here"):
121
+ raise ValueError(
122
+ "Missing AI API Key! Please configure OPENAI_API_KEY (or GROQ_API_KEY) in Settings (βš™οΈ), "
123
+ "or run `gitradar config --openai-api-key YOUR_KEY`."
124
+ )
125
+
126
+ def _fetch_active_models(self) -> List[str]:
127
+ """Dynamically query the OpenAI-compatible endpoint for available models."""
128
+ try:
129
+ res = self.client.models.list()
130
+ models = [m.id for m in res.data]
131
+ text_models = [
132
+ m for m in models
133
+ if not any(x in m.lower() for x in ["whisper", "tts", "embedding", "dall-e", "guard", "moderation"])
134
+ ]
135
+ return text_models
136
+ except Exception:
137
+ return []
138
+
139
+ def _completion_with_fallback(self, messages: List[dict], temperature: float = 0.3) -> dict:
140
+ """Try primary model, fallback models, and handle JSON mode variations."""
141
+ candidates = [self.model]
142
+ if self.base_url and "groq.com" in self.base_url:
143
+ candidates.extend([
144
+ "llama-3.3-70b-versatile",
145
+ "llama-3.1-8b-instant",
146
+ "openai/gpt-oss-120b",
147
+ "qwen/qwen3.6-27b",
148
+ ])
149
+ else:
150
+ candidates.extend([
151
+ "gpt-4o-mini",
152
+ "gpt-4o",
153
+ "gpt-3.5-turbo",
154
+ ])
155
+
156
+ models_to_try = []
157
+ for m in candidates:
158
+ if m and m not in models_to_try:
159
+ models_to_try.append(m)
160
+
161
+ last_exception = None
162
+
163
+ for model_name in models_to_try:
164
+ # 1. First attempt: JSON object response format
165
+ try:
166
+ res = self.client.chat.completions.create(
167
+ model=model_name,
168
+ messages=messages,
169
+ temperature=temperature,
170
+ response_format={"type": "json_object"},
171
+ )
172
+ content = res.choices[0].message.content
173
+ return extract_json(content)
174
+ except AuthenticationError as ae:
175
+ raise ValueError(
176
+ f"Authentication Failed with AI Provider ({self.base_url or 'OpenAI'}): Invalid API Key. "
177
+ "Please update your key in Settings (βš™οΈ) or via `gitradar config`."
178
+ ) from ae
179
+ except Exception as e:
180
+ err_str = str(e).lower()
181
+ if any(k in err_str for k in ["invalid_api_key", "incorrect api key", "unauthorized"]):
182
+ raise ValueError(
183
+ f"Authentication Failed with AI Provider ({self.base_url or 'OpenAI'}): Invalid API Key. "
184
+ "Please update your key in Settings (βš™οΈ) or via `gitradar config`."
185
+ ) from e
186
+ last_exception = e
187
+
188
+ # 2. Second attempt: Plain text completion (fallback for providers without response_format support)
189
+ try:
190
+ res = self.client.chat.completions.create(
191
+ model=model_name,
192
+ messages=messages,
193
+ temperature=temperature,
194
+ )
195
+ content = res.choices[0].message.content
196
+ return extract_json(content)
197
+ except AuthenticationError as ae:
198
+ raise ValueError(
199
+ f"Authentication Failed with AI Provider ({self.base_url or 'OpenAI'}): Invalid API Key. "
200
+ "Please update your key in Settings (βš™οΈ) or via `gitradar config`."
201
+ ) from ae
202
+ except Exception as e:
203
+ err_str = str(e).lower()
204
+ if any(k in err_str for k in ["invalid_api_key", "incorrect api key", "unauthorized"]):
205
+ raise ValueError(
206
+ f"Authentication Failed with AI Provider ({self.base_url or 'OpenAI'}): Invalid API Key. "
207
+ "Please update your key in Settings (βš™οΈ) or via `gitradar config`."
208
+ ) from e
209
+ last_exception = e
210
+ continue
211
+
212
+ if last_exception:
213
+ raise last_exception
214
+ raise RuntimeError("All model fallback attempts failed.")
215
+
216
+ def expand_idea_to_queries(self, idea: str, language: str = None) -> ExpandedQueries:
217
+ """Use LLM to generate search keywords and GitHub topics based on the project idea."""
218
+ self._ensure_api_key()
219
+ lang = language or self.language or settings.default_language
220
+
221
+ system_prompt = render_prompt("query_expansion_system", language=lang)
222
+ user_prompt = render_prompt("query_expansion_user", idea=idea)
223
+
224
+ data = self._completion_with_fallback(
225
+ messages=[
226
+ {"role": "system", "content": system_prompt},
227
+ {"role": "user", "content": user_prompt},
228
+ ],
229
+ temperature=0.0,
230
+ )
231
+
232
+ if not isinstance(data, dict):
233
+ data = {}
234
+
235
+ if "search_keywords" in data and not isinstance(data["search_keywords"], list):
236
+ data["search_keywords"] = [str(data["search_keywords"])]
237
+ if "github_topics" in data and not isinstance(data["github_topics"], list):
238
+ data["github_topics"] = [str(data["github_topics"])]
239
+
240
+ try:
241
+ return ExpandedQueries(**data)
242
+ except Exception:
243
+ return ExpandedQueries(
244
+ search_keywords=data.get("search_keywords") or [idea],
245
+ github_topics=data.get("github_topics") or [],
246
+ target_languages=data.get("target_languages") or [],
247
+ search_explanation=str(data.get("search_explanation") or "Search strategy generated."),
248
+ )
249
+
250
+ def analyze_market_and_gaps(self, idea: str, repositories: List[RepositoryInfo], language: str = None) -> GapAnalysisReport:
251
+ """Analyze market saturation, identify gaps, differentiators, and produce an analysis report."""
252
+ self._ensure_api_key()
253
+ lang = language or self.language or settings.default_language
254
+
255
+ system_prompt = render_prompt("gap_analysis_system", language=lang)
256
+ user_prompt = render_prompt("gap_analysis_user", idea=idea, repositories=repositories)
257
+
258
+ data = self._completion_with_fallback(
259
+ messages=[
260
+ {"role": "system", "content": system_prompt},
261
+ {"role": "user", "content": user_prompt},
262
+ ],
263
+ temperature=0.4,
264
+ )
265
+
266
+ if not isinstance(data, dict):
267
+ data = {}
268
+
269
+ for score_key in ("saturation_score", "opportunity_score"):
270
+ if score_key in data:
271
+ try:
272
+ data[score_key] = int(data[score_key])
273
+ except (ValueError, TypeError):
274
+ data[score_key] = 50
275
+
276
+ try:
277
+ return GapAnalysisReport(**data)
278
+ except Exception:
279
+ return GapAnalysisReport(
280
+ idea_summary=str(data.get("idea_summary") or idea),
281
+ market_saturation=str(data.get("market_saturation") or "Moderate"),
282
+ saturation_score=int(data.get("saturation_score") or 50),
283
+ market_summary=str(data.get("market_summary") or "Market analysis completed."),
284
+ unmet_needs=list(data.get("unmet_needs") or []),
285
+ differentiators=list(data.get("differentiators") or []),
286
+ actionable_recommendations=list(data.get("actionable_recommendations") or []),
287
+ opportunity_score=int(data.get("opportunity_score") or 80),
288
+ )
289
+
290
+ def evaluate_repository_relevance(
291
+ self,
292
+ idea: str,
293
+ repositories: List[RepositoryInfo],
294
+ language: str = None
295
+ ) -> List[RepositoryInfo]:
296
+ """Evaluate LLM relevance scores and fit reasons for a list of candidate repositories."""
297
+ if not repositories:
298
+ return repositories
299
+
300
+ try:
301
+ self._ensure_api_key()
302
+ target_lang = language or self.language or settings.default_language
303
+
304
+ sys_msg = render_prompt("relevance_evaluation_system", language=target_lang)
305
+ user_msg = render_prompt("relevance_evaluation_user", idea=idea, repos=repositories)
306
+
307
+ data = self._completion_with_fallback(
308
+ messages=[
309
+ {"role": "system", "content": sys_msg},
310
+ {"role": "user", "content": user_msg},
311
+ ],
312
+ temperature=0.0,
313
+ )
314
+
315
+ if isinstance(data, dict):
316
+ evals = data.get("evaluations", [])
317
+ eval_map = {item.get("full_name"): item for item in evals if isinstance(item, dict)}
318
+
319
+ for repo in repositories:
320
+ if repo.full_name in eval_map:
321
+ ev = eval_map[repo.full_name]
322
+ score = ev.get("relevance_score")
323
+ if isinstance(score, (int, float)):
324
+ repo.relevance_score = max(0, min(100, int(score)))
325
+ else:
326
+ repo.relevance_score = 50
327
+ repo.is_direct_competitor = bool(ev.get("is_direct_competitor", repo.relevance_score >= 60))
328
+ repo.relevance_reason = str(ev.get("relevance_reason") or "Evaluated fit against project idea.")
329
+ else:
330
+ repo.relevance_score = 50
331
+ repo.is_direct_competitor = True
332
+ repo.relevance_reason = "Search candidate."
333
+ except Exception:
334
+ for repo in repositories:
335
+ if repo.relevance_score is None:
336
+ repo.relevance_score = 50
337
+ repo.is_direct_competitor = True
338
+ if not repo.relevance_reason:
339
+ repo.relevance_reason = "Keyword search match."
340
+
341
+ return repositories
342
+
@@ -0,0 +1,196 @@
1
+ from typing import List
2
+ from rich.console import Console
3
+ from rich.panel import Panel
4
+ from rich.table import Table
5
+ from rich.text import Text
6
+ from rich.columns import Columns
7
+ from rich.markdown import Markdown
8
+ from gitradar import __version__
9
+ from gitradar.models import ExpandedQueries, GapAnalysisReport, RepositoryInfo
10
+
11
+ console = Console()
12
+
13
+
14
+ def display_banner() -> None:
15
+ """Print stylish GitRadar terminal banner."""
16
+ banner_text = Text()
17
+ banner_text.append("πŸ“‘ ", style="bold cyan")
18
+ banner_text.append("GitRadar", style="bold white on blue")
19
+ banner_text.append(" CLI Market & Gap Analysis Tool ", style="bold cyan")
20
+ banner_text.append(f"[v{__version__}]", style="dim italic white")
21
+
22
+ console.print()
23
+ console.print(Panel(banner_text, border_style="cyan", expand=False))
24
+ console.print()
25
+
26
+
27
+ def display_query_strategy(queries: ExpandedQueries) -> None:
28
+ """Display the AI generated search query expansion strategy."""
29
+ table = Table(show_header=False, box=None, padding=(0, 1))
30
+ table.add_column("Key", style="bold yellow")
31
+ table.add_column("Value", style="cyan")
32
+
33
+ table.add_row("πŸ” Search Keywords:", ", ".join(queries.search_keywords))
34
+ if queries.github_topics:
35
+ table.add_row("🏷️ GitHub Topics:", ", ".join(f"#{t}" for t in queries.github_topics))
36
+ if queries.target_languages:
37
+ table.add_row("πŸ’» Target Languages:", ", ".join(queries.target_languages))
38
+ table.add_row("πŸ’‘ Search Strategy:", queries.search_explanation)
39
+
40
+ console.print(
41
+ Panel(
42
+ table,
43
+ title="[bold green]🧠 LLM Search Strategy Generated[/bold green]",
44
+ border_style="green",
45
+ expand=True,
46
+ )
47
+ )
48
+ console.print()
49
+
50
+
51
+ def display_repo_table(repositories: List[RepositoryInfo], title: str = "Relevant GitHub Repositories Found") -> None:
52
+ """Display repositories in a Rich styled table."""
53
+ if not repositories:
54
+ console.print("[bold red]No relevant repositories found.[/bold red]")
55
+ return
56
+
57
+ table = Table(
58
+ title=f"πŸ“Š {title} ({len(repositories)} Repos)",
59
+ header_style="bold magenta",
60
+ border_style="dim white",
61
+ expand=True,
62
+ )
63
+
64
+ table.add_column("#", justify="center", style="dim", width=3)
65
+ table.add_column("Repository", style="bold cyan", no_wrap=True)
66
+ table.add_column("Relevance 🎯", justify="center", style="bold")
67
+ table.add_column("Stars ⭐", justify="right", style="bold yellow")
68
+ table.add_column("Forks 🍴", justify="right", style="dim cyan")
69
+ table.add_column("Language πŸ’»", style="green")
70
+ table.add_column("Last Updated πŸ“…", justify="center", style="dim")
71
+ table.add_column("Description & Match Fit", style="white")
72
+
73
+ for idx, repo in enumerate(repositories, 1):
74
+ desc = repo.description or ""
75
+ if len(desc) > 50:
76
+ desc = desc[:47] + "..."
77
+
78
+ if repo.relevance_reason:
79
+ desc += f"\n[dim italic]Fit: {repo.relevance_reason}[/dim italic]"
80
+
81
+ rel_badge = "N/A"
82
+ if repo.relevance_score is not None:
83
+ if repo.relevance_score >= 80:
84
+ rel_badge = f"[bold green]🎯 {repo.relevance_score}% Match[/bold green]"
85
+ elif repo.relevance_score >= 50:
86
+ rel_badge = f"[bold yellow]⚑ {repo.relevance_score}% Match[/bold yellow]"
87
+ else:
88
+ rel_badge = f"[bold red]⚠️ {repo.relevance_score}% Match[/bold red]"
89
+
90
+ table.add_row(
91
+ str(idx),
92
+ f"[{repo.html_url}]{repo.full_name}[/]",
93
+ rel_badge,
94
+ f"{repo.stars:,}",
95
+ f"{repo.forks:,}",
96
+ repo.language or "N/A",
97
+ repo.updated_at or "N/A",
98
+ desc,
99
+ )
100
+
101
+ console.print(table)
102
+ console.print()
103
+
104
+
105
+ def display_gap_report(report: GapAnalysisReport) -> None:
106
+ """Display comprehensive Market & Gap Analysis Report in terminal UI."""
107
+ console.print()
108
+ console.print("[bold cyan]════════════════════════════════════════════════════════════════════════════════[/bold cyan]")
109
+ console.print(" 🎯 [bold white on blue] MARKET & GAP ANALYSIS REPORT [/bold white on blue]")
110
+ console.print("[bold cyan]════════════════════════════════════════════════════════════════════════════════[/bold cyan]")
111
+ console.print()
112
+
113
+ # 1. Summary & Market Saturation Gauge
114
+ sat_color = "green" if report.market_saturation in ["Düşük", "Low"] else ("yellow" if report.market_saturation in ["Orta", "Moderate"] else "red")
115
+
116
+ summary_text = (
117
+ f"[bold white]Project Idea Summary:[/bold white] {report.idea_summary}\n\n"
118
+ f"[bold white]Market Saturation:[/bold white] [{sat_color}]{report.market_saturation}[/{sat_color}] "
119
+ f"(Saturation Score: [bold]{report.saturation_score}/100[/bold]) | "
120
+ f"[bold white]Opportunity Score:[/bold white] [bold green]{report.opportunity_score}/100 πŸš€[/bold green]\n\n"
121
+ f"[bold white]Market Summary:[/bold white]\n{report.market_summary}"
122
+ )
123
+ console.print(Panel(summary_text, title="[bold yellow]πŸ“Œ Market Overview & Saturation Gauge[/bold yellow]", border_style=sat_color))
124
+ console.print()
125
+
126
+ # 2. Key Competitors
127
+ if report.top_competitors:
128
+ comp_table = Table(title="πŸ† Top Competitors & Analysis", header_style="bold blue", expand=True)
129
+ comp_table.add_column("Competitor Repo", style="bold cyan", width=25)
130
+ comp_table.add_column("Key Strengths", style="green")
131
+ comp_table.add_column("Weaknesses & Gaps", style="red")
132
+
133
+ for comp in report.top_competitors:
134
+ strengths = "\n".join(f"β€’ {s}" for s in comp.key_strengths)
135
+ weaknesses = "\n".join(f"β€’ {w}" for w in comp.weaknesses_or_gaps)
136
+ comp_table.add_row(comp.repo_name, strengths, weaknesses)
137
+
138
+ console.print(comp_table)
139
+ console.print()
140
+
141
+ # 3. Unmet Needs & Differentiators
142
+ unmet_md = "### 🚨 Unmet Needs & Ecosystem Gaps\n"
143
+ for gap in report.unmet_needs:
144
+ unmet_md += f"- ❌ **{gap}**\n"
145
+
146
+ diff_md = "### πŸ’Ž Key Differentiators\n"
147
+ for diff in report.differentiators:
148
+ diff_md += f"- ✨ **{diff}**\n"
149
+
150
+ console.print(
151
+ Columns(
152
+ [
153
+ Panel(Markdown(unmet_md), border_style="red", title="[bold red]Unmet Needs (Gaps)[/bold red]", expand=True),
154
+ Panel(Markdown(diff_md), border_style="green", title="[bold green]Key Differentiators[/bold green]", expand=True),
155
+ ],
156
+ equal=True,
157
+ )
158
+ )
159
+ console.print()
160
+
161
+ # 4. Actionable Recommendations
162
+ rec_md = "### πŸ’‘ Strategic Developer Recommendations\n"
163
+ for idx, rec in enumerate(report.actionable_recommendations, 1):
164
+ rec_md += f"**{idx}.** {rec}\n"
165
+
166
+ console.print(Panel(Markdown(rec_md), title="[bold cyan]πŸ› οΈ Action Plan & Recommendations[/bold cyan]", border_style="cyan"))
167
+ console.print()
168
+
169
+ # 5. Technical Implementation Guide & Open Source Building Blocks
170
+ if report.implementation_guide:
171
+ guide = report.implementation_guide
172
+ guide_md = ""
173
+ if guide.recommended_tech_stack:
174
+ techs = ", ".join(f"`{t}`" for t in guide.recommended_tech_stack)
175
+ guide_md += f"**Recommended Tech Stack:** {techs}\n\n"
176
+ if guide.architecture_overview:
177
+ guide_md += f"**Architecture Overview:**\n{guide.architecture_overview}\n\n"
178
+
179
+ if guide.open_source_building_blocks:
180
+ guide_md += "### πŸ“¦ Recommended Open-Source Building Blocks\n"
181
+ for tool in guide.open_source_building_blocks:
182
+ link_str = f" ([link]({tool.repo_url}))" if tool.repo_url else ""
183
+ guide_md += f"- **{tool.name}** `[{tool.category}]`{link_str}: {tool.description_and_usage}\n"
184
+
185
+ console.print(Panel(Markdown(guide_md), title="[bold magenta]βš™οΈ Technical Implementation & Open-Source Roadmap[/bold magenta]", border_style="magenta"))
186
+ console.print()
187
+
188
+
189
+ def display_error(message: str) -> None:
190
+ """Print an error panel."""
191
+ console.print(Panel(f"[bold red]ERROR:[/bold red] {message}", title="❌ Operation Failed", border_style="red"))
192
+
193
+
194
+ def display_info(message: str) -> None:
195
+ """Print an info callout."""
196
+ console.print(f"[bold blue]ℹ️ {message}[/bold blue]")
@@ -0,0 +1,7 @@
1
+ """
2
+ GitRadar Web Dashboard Module.
3
+ """
4
+
5
+ from .app import create_app
6
+
7
+ __all__ = ["create_app"]