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.
- demo/api/index.py +14 -0
- demo/gitradar/__init__.py +22 -0
- demo/gitradar/cli.py +214 -0
- demo/gitradar/config.py +117 -0
- demo/gitradar/models.py +67 -0
- demo/gitradar/prompts/__init__.py +18 -0
- demo/gitradar/services/github.py +179 -0
- demo/gitradar/services/llm.py +342 -0
- demo/gitradar/utils/ui.py +196 -0
- demo/gitradar/web/__init__.py +7 -0
- demo/gitradar/web/app.py +173 -0
- examples/python_sdk_usage.py +48 -0
- gitradar/__init__.py +22 -0
- gitradar/cli.py +214 -0
- gitradar/config.py +117 -0
- gitradar/models.py +67 -0
- gitradar/prompts/__init__.py +18 -0
- gitradar/prompts/gap_analysis_system.j2 +36 -0
- gitradar/prompts/gap_analysis_user.j2 +21 -0
- gitradar/prompts/query_expansion_system.j2 +19 -0
- gitradar/prompts/query_expansion_user.j2 +1 -0
- gitradar/prompts/relevance_evaluation_system.j2 +22 -0
- gitradar/prompts/relevance_evaluation_user.j2 +14 -0
- gitradar/services/github.py +179 -0
- gitradar/services/llm.py +342 -0
- gitradar/utils/ui.py +196 -0
- gitradar/web/__init__.py +7 -0
- gitradar/web/app.py +173 -0
- gitradar/web/static/apple-touch-icon.png +0 -0
- gitradar/web/static/css/style.css +1430 -0
- gitradar/web/static/favicon-96x96.png +0 -0
- gitradar/web/static/favicon.ico +0 -0
- gitradar/web/static/favicon.svg +1 -0
- gitradar/web/static/js/app.js +1206 -0
- gitradar/web/static/site.webmanifest +21 -0
- gitradar/web/static/web-app-manifest-192x192.png +0 -0
- gitradar/web/static/web-app-manifest-512x512.png +0 -0
- gitradar/web/templates/index.html +521 -0
- gitradar-0.1.5.dist-info/METADATA +251 -0
- gitradar-0.1.5.dist-info/RECORD +51 -0
- gitradar-0.1.5.dist-info/WHEEL +5 -0
- gitradar-0.1.5.dist-info/entry_points.txt +2 -0
- gitradar-0.1.5.dist-info/licenses/LICENSE +21 -0
- gitradar-0.1.5.dist-info/top_level.txt +4 -0
- tests/conftest.py +27 -0
- tests/test_cli.py +29 -0
- tests/test_llm.py +163 -0
- tests/test_models.py +90 -0
- tests/test_prompts.py +58 -0
- tests/test_relevance.py +75 -0
- 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]")
|