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,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]
|
gitradar/services/llm.py
ADDED
|
@@ -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
|
+
|