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,36 @@
1
+ You are an expert in Market & Gap Analysis for software developer tools.
2
+ You will be given a new developer project idea and a list of existing candidate open-source repositories found on GitHub.
3
+ Your task is to analyze these repositories semantically and generate an executive market report.
4
+ Provide ALL report text contents (summaries, strengths, gaps, differentiators, recommendations, architecture) strictly in {{ language | default('English') }}.
5
+ Return ONLY a valid JSON object matching the requested schema below.
6
+
7
+ Required JSON Schema:
8
+ {
9
+ "idea_summary": "Concise summary of the project idea",
10
+ "market_saturation": "Low | Moderate | High",
11
+ "saturation_score": 45,
12
+ "market_summary": "Overview of the current market state...",
13
+ "top_competitors": [
14
+ {
15
+ "repo_name": "owner/repo",
16
+ "key_strengths": ["Strength 1", "Strength 2"],
17
+ "weaknesses_or_gaps": ["Weakness or Gap 1", "Gap 2"]
18
+ }
19
+ ],
20
+ "unmet_needs": ["Unmet need or gap in existing repos 1", "Gap 2"],
21
+ "differentiators": ["Feature to differentiate project 1", "Feature 2"],
22
+ "actionable_recommendations": ["Recommendation 1", "Recommendation 2"],
23
+ "opportunity_score": 85,
24
+ "implementation_guide": {
25
+ "recommended_tech_stack": ["Python / Rust", "Typer", "LiteLLM", "Qdrant"],
26
+ "architecture_overview": "Comprehensive explanation of how this software should be built, structured, and architected step-by-step...",
27
+ "open_source_building_blocks": [
28
+ {
29
+ "name": "Open-Source Tool Name",
30
+ "category": "CLI Framework | Vector DB | LLM SDK | Parser | UI | Async Engine",
31
+ "description_and_usage": "Detailed guidance on how and why to leverage this specific open-source library in building the project.",
32
+ "repo_url": "https://github.com/owner/repo"
33
+ }
34
+ ]
35
+ }
36
+ }
@@ -0,0 +1,21 @@
1
+ Developer Project Idea:
2
+ {{ idea }}
3
+
4
+ Existing Candidate Repositories Found on GitHub ({{ repositories|length }} items):
5
+ {% if repositories %}
6
+ {% for r in repositories %}
7
+ {{ loop.index }}. Repo: {{ r.full_name }}
8
+ Stars: {{ r.stars }} | Forks: {{ r.forks }} | Language: {{ r.language }}
9
+ Description: {{ r.description }}
10
+ Topics: {{ r.topics|join(', ') }}
11
+ {% if r.readme_snippet %}
12
+ README Snippet: {{ r.readme_snippet[:300] }}...
13
+ {% endif %}
14
+
15
+ {% endfor %}
16
+ {% else %}
17
+ No directly relevant repositories found.
18
+ {% endif %}
19
+
20
+ CRITICAL LANGUAGE INSTRUCTION:
21
+ Generate ALL report text content (idea_summary, market_summary, key_strengths, weaknesses_or_gaps, unmet_needs, differentiators, actionable_recommendations, architecture_overview, and open_source_building_blocks descriptions) strictly in {{ language | default('English') }}.
@@ -0,0 +1,19 @@
1
+ You are a Senior GitHub and Software Architect.
2
+ The user will provide a software project idea.
3
+ Your task is to derive targeted, highly specific search keywords, exact key phrases, GitHub topics, and target languages to query the GitHub REST API efficiently.
4
+
5
+ Guidelines for `search_keywords`:
6
+ - Include multi-word specific key phrases (e.g., "terminal code review", "git diff analyzer", "k8s log explainer") to target niche projects rather than overly generic single words.
7
+ - Focus on the core functionality, domain, and problem solved by the project idea.
8
+ - Provide 3 to 6 distinct keywords/phrases.
9
+
10
+ Provide search_explanation text in {{ language | default('English') }}. Note that search keywords and GitHub topics must remain in English for GitHub API query accuracy.
11
+ Return ONLY a valid JSON object matching the requested schema below.
12
+
13
+ Required JSON Schema:
14
+ {
15
+ "search_keywords": ["specific phrase 1", "targeted keyword 2", "domain keyword 3"],
16
+ "github_topics": ["topic1", "topic2"],
17
+ "target_languages": ["Python", "Rust"],
18
+ "search_explanation": "Brief explanation of the search strategy"
19
+ }
@@ -0,0 +1 @@
1
+ Project Idea: {{ idea }}
@@ -0,0 +1,22 @@
1
+ You are an expert Software Product Manager and Competitive Intelligence Analyst.
2
+ Given a developer project idea concept and a list of candidate GitHub repositories found via search, your task is to evaluate the relevance of each repository against the core concept of the project idea.
3
+
4
+ For each repository in the list, evaluate:
5
+ 1. `relevance_score`: Integer from 0 to 100 representing how closely the repository matches or competes with the core purpose of the project idea.
6
+ - 80-100: Direct match or primary competitor offering similar core functionality.
7
+ - 50-79: Indirect match or partial overlap (solves part of the problem or related tooling).
8
+ - 0-49: Low relevance, generic infrastructure, or tangentially related project matching only surface keywords.
9
+ 2. `is_direct_competitor`: Boolean (true if relevance_score >= 60, false otherwise).
10
+ 3. `relevance_reason`: Brief 1-sentence explanation of why it matches or differs from the user's idea in {{ language | default('English') }}.
11
+
12
+ Return ONLY a valid JSON object matching the schema below:
13
+ {
14
+ "evaluations": [
15
+ {
16
+ "full_name": "owner/repo",
17
+ "relevance_score": 85,
18
+ "is_direct_competitor": true,
19
+ "relevance_reason": "Brief explanation of fit"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,14 @@
1
+ Developer Project Idea: {{ idea }}
2
+
3
+ Candidate Repositories to Evaluate:
4
+ {% for repo in repos %}
5
+ - Repository: {{ repo.full_name }}
6
+ Description: {{ repo.description }}
7
+ Topics: {{ repo.topics | join(', ') if repo.topics else 'None' }}
8
+ Language: {{ repo.language }}
9
+ Stars: {{ repo.stars }}
10
+ {% if repo.readme_snippet %}
11
+ README Snippet: {{ repo.readme_snippet[:300] }}
12
+ {% endif %}
13
+
14
+ {% endfor %}
@@ -0,0 +1,179 @@
1
+ import asyncio
2
+ from typing import List, Optional, Dict, Any
3
+ import httpx
4
+ from gitradar.config import settings
5
+ from gitradar.models import RepositoryInfo
6
+
7
+ GITHUB_API_BASE = "https://api.github.com"
8
+
9
+
10
+ class GitHubService:
11
+ """Asynchronous client for interacting with the GitHub REST API."""
12
+
13
+ def __init__(self, token: Optional[str] = None):
14
+ self.token = token or settings.github_token
15
+ self.headers = {
16
+ "Accept": "application/vnd.github.v3+json",
17
+ "User-Agent": "GitRadar-CLI/0.1.0",
18
+ }
19
+ if self.token:
20
+ self.headers["Authorization"] = f"Bearer {self.token}"
21
+
22
+ async def search_repositories(
23
+ self,
24
+ query: str,
25
+ limit: int = 10,
26
+ sort: str = "stars",
27
+ order: str = "desc"
28
+ ) -> List[RepositoryInfo]:
29
+ """Search GitHub repositories based on query string."""
30
+ url = f"{GITHUB_API_BASE}/search/repositories"
31
+ params = {
32
+ "q": query,
33
+ "sort": sort,
34
+ "order": order,
35
+ "per_page": min(limit, 100),
36
+ }
37
+
38
+ async with httpx.AsyncClient(timeout=15.0) as client:
39
+ response = await client.get(url, headers=self.headers, params=params)
40
+
41
+ if response.status_code == 403:
42
+ raise RuntimeError(
43
+ "GitHub API Rate Limit Exceeded. "
44
+ "Please configure a GITHUB_TOKEN (`gitradar config --github-token YOUR_TOKEN`)."
45
+ )
46
+ elif response.status_code != 200:
47
+ raise RuntimeError(f"GitHub API Error ({response.status_code}): {response.text}")
48
+
49
+ data = response.json()
50
+ items = data.get("items", [])
51
+
52
+ results: List[RepositoryInfo] = []
53
+ for item in items[:limit]:
54
+ repo = RepositoryInfo(
55
+ full_name=item.get("full_name", ""),
56
+ name=item.get("name", ""),
57
+ owner=item.get("owner", {}).get("login", ""),
58
+ html_url=item.get("html_url", ""),
59
+ description=item.get("description") or "No description provided",
60
+ stars=item.get("stargazers_count", 0),
61
+ forks=item.get("forks_count", 0),
62
+ language=item.get("language") or "Unspecified",
63
+ topics=item.get("topics", []),
64
+ updated_at=item.get("updated_at", "")[:10] if item.get("updated_at") else "",
65
+ open_issues=item.get("open_issues_count", 0),
66
+ )
67
+ results.append(repo)
68
+
69
+ return results
70
+
71
+ async def fetch_readme_snippet(self, owner: str, repo: str, max_chars: int = 800) -> Optional[str]:
72
+ """Fetch README content snippet for a repository."""
73
+ url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/readme"
74
+ headers = {**self.headers, "Accept": "application/vnd.github.v3.raw"}
75
+
76
+ async with httpx.AsyncClient(timeout=10.0) as client:
77
+ try:
78
+ response = await client.get(url, headers=headers)
79
+ if response.status_code == 200:
80
+ text = response.text
81
+ cleaned = text.strip()
82
+ return cleaned[:max_chars] + "..." if len(cleaned) > max_chars else cleaned
83
+ except Exception:
84
+ pass
85
+ return None
86
+
87
+ async def search_and_enrich(
88
+ self,
89
+ keywords: List[str],
90
+ topics: List[str] = None,
91
+ limit: int = 10,
92
+ fetch_readmes: bool = True
93
+ ) -> List[RepositoryInfo]:
94
+ """
95
+ Execute smart combined search using keywords & topics, deduplicate results,
96
+ and enrich top results with README snippets asynchronously.
97
+ """
98
+ all_repos: Dict[str, RepositoryInfo] = {}
99
+
100
+ queries = []
101
+ if keywords:
102
+ for kw in keywords:
103
+ kw_clean = kw.strip()
104
+ if kw_clean and kw_clean not in queries:
105
+ queries.append(kw_clean)
106
+
107
+ if topics:
108
+ for topic in topics[:3]:
109
+ topic_clean = topic.strip().replace(" ", "-").lower()
110
+ if topic_clean and topic_clean not in ["python", "machine-learning", "deep-learning", "ai", "artificial-intelligence"]:
111
+ t_query = f"topic:{topic_clean}"
112
+ if t_query not in queries:
113
+ queries.append(t_query)
114
+
115
+ tasks = [self.search_repositories(q, limit=limit) for q in queries]
116
+ search_results = await asyncio.gather(*tasks, return_exceptions=True)
117
+
118
+ for res in search_results:
119
+ if isinstance(res, list):
120
+ for repo in res:
121
+ if repo.full_name not in all_repos:
122
+ all_repos[repo.full_name] = repo
123
+
124
+ sorted_repos = sorted(all_repos.values(), key=lambda r: (r.stars, r.forks, r.full_name), reverse=True)[:limit]
125
+
126
+ if fetch_readmes and sorted_repos:
127
+ readme_tasks = [
128
+ self.fetch_readme_snippet(repo.owner, repo.name)
129
+ for repo in sorted_repos[:5]
130
+ ]
131
+ readmes = await asyncio.gather(*readme_tasks, return_exceptions=True)
132
+
133
+ for i, readme in enumerate(readmes):
134
+ if isinstance(readme, str) and readme:
135
+ sorted_repos[i].readme_snippet = readme
136
+
137
+ return sorted_repos
138
+
139
+ def rank_and_sort_by_relevance(
140
+ self,
141
+ repos: List[RepositoryInfo],
142
+ limit: int = 10,
143
+ min_relevance: int = 50,
144
+ ) -> List[RepositoryInfo]:
145
+ """
146
+ Calculate hybrid score for each repository, filter out repos below min_relevance threshold,
147
+ and sort by hybrid score desc.
148
+ Hybrid Score = (Relevance Score * 0.7) + (Normalized Star Score * 0.3)
149
+ """
150
+ if not repos:
151
+ return repos
152
+
153
+ import math
154
+ max_stars = max((r.stars for r in repos), default=1)
155
+
156
+ for r in repos:
157
+ rel_score = r.relevance_score if r.relevance_score is not None else 50
158
+ star_score = min(100.0, (math.log10(r.stars + 1) / math.log10(max(max_stars, 10) + 1)) * 100.0) if max_stars > 0 else 50.0
159
+ r.hybrid_score = round((rel_score * 0.7) + (star_score * 0.3), 1)
160
+
161
+ # Enforce minimum relevance threshold filtering
162
+ filtered_repos = [
163
+ r for r in repos
164
+ if r.relevance_score is not None and r.relevance_score >= min_relevance
165
+ ]
166
+
167
+ if not filtered_repos:
168
+ fallback_threshold = max(30, min_relevance - 20)
169
+ filtered_repos = [
170
+ r for r in repos
171
+ if r.relevance_score is not None and r.relevance_score >= fallback_threshold
172
+ ]
173
+
174
+ sorted_repos = sorted(
175
+ filtered_repos if filtered_repos else repos,
176
+ key=lambda r: (r.hybrid_score, r.stars, r.forks, r.full_name),
177
+ reverse=True
178
+ )
179
+ return sorted_repos[:limit]
@@ -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
+