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,173 @@
1
+ import asyncio
2
+ import concurrent.futures
3
+ from pathlib import Path
4
+ from flask import Flask, render_template, request, jsonify, send_from_directory
5
+ from gitradar.config import settings
6
+ from gitradar.services.github import GitHubService
7
+ from gitradar.services.llm import LLMService
8
+
9
+ BASE_DIR = Path(__file__).parent
10
+
11
+
12
+ def safe_run_async(coro):
13
+ """Run an async coroutine safely, handling cases where an asyncio event loop is already running (e.g. Vercel / ASGI)."""
14
+ try:
15
+ loop = asyncio.get_running_loop()
16
+ except RuntimeError:
17
+ loop = None
18
+
19
+ if loop and loop.is_running():
20
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
21
+ return executor.submit(asyncio.run, coro).result()
22
+ else:
23
+ return asyncio.run(coro)
24
+
25
+
26
+ def create_app() -> Flask:
27
+ """Create and configure the Flask web application instance."""
28
+ app = Flask(
29
+ __name__,
30
+ template_folder=str(BASE_DIR / "templates"),
31
+ static_folder=str(BASE_DIR / "static"),
32
+ )
33
+
34
+ @app.route("/")
35
+ def index():
36
+ return render_template("index.html")
37
+
38
+ @app.route("/<path:filename>")
39
+ def serve_static_root(filename):
40
+ target = BASE_DIR / "static" / filename
41
+ if target.is_file():
42
+ return send_from_directory(BASE_DIR / "static", filename)
43
+ return jsonify({"error": "Not found"}), 404
44
+
45
+ @app.route("/api/config", methods=["GET"])
46
+ def get_config():
47
+ return jsonify({
48
+ "openai_configured": bool(settings.openai_api_key or settings.groq_api_key),
49
+ "groq_configured": bool(settings.groq_api_key),
50
+ "github_configured": bool(settings.github_token),
51
+ "default_model": settings.default_model,
52
+ "default_language": settings.default_language,
53
+ "max_repos_to_analyze": settings.max_repos_to_analyze,
54
+ "base_url": settings.openai_base_url,
55
+ })
56
+
57
+ @app.route("/api/analyze", methods=["POST"])
58
+ def analyze_idea():
59
+ data = request.get_json() or {}
60
+ idea = data.get("idea", "").strip()
61
+ limit = int(data.get("limit", 10))
62
+ model = data.get("model") or settings.default_model
63
+ language = data.get("language") or settings.default_language
64
+
65
+ def clean_key(v):
66
+ if not v:
67
+ return None
68
+ cleaned = str(v).replace("\r", "").replace("\n", "").replace("\t", "").strip().strip("'\"")
69
+ return cleaned if cleaned else None
70
+
71
+ # Extract user credentials and OpenAI-compatible endpoint from headers or JSON payload
72
+ api_key = clean_key(
73
+ request.headers.get("X-OpenAI-Api-Key")
74
+ or request.headers.get("X-Groq-Api-Key")
75
+ or data.get("openai_key")
76
+ or data.get("openaiKey")
77
+ or data.get("api_key")
78
+ or data.get("groq_key")
79
+ or data.get("groqKey")
80
+ or settings.openai_api_key
81
+ or settings.groq_api_key
82
+ )
83
+ base_url = clean_key(
84
+ request.headers.get("X-OpenAI-Base-Url")
85
+ or data.get("base_url")
86
+ or data.get("baseUrl")
87
+ or settings.openai_base_url
88
+ )
89
+ github_token = clean_key(
90
+ request.headers.get("X-Github-Token")
91
+ or data.get("github_token")
92
+ or data.get("githubToken")
93
+ or settings.github_token
94
+ )
95
+
96
+ if not idea:
97
+ return jsonify({"error": "Project idea parameter is required."}), 400
98
+
99
+ try:
100
+ llm_service = LLMService(api_key=api_key, base_url=base_url, model=model, language=language)
101
+ github_service = GitHubService(token=github_token)
102
+
103
+ # 1. Expand Queries
104
+ queries = llm_service.expand_idea_to_queries(idea, language=language)
105
+
106
+ # 2. Search GitHub Repositories
107
+ raw_repos = safe_run_async(
108
+ github_service.search_and_enrich(
109
+ keywords=queries.search_keywords,
110
+ topics=queries.github_topics,
111
+ limit=max(limit * 2, 15),
112
+ )
113
+ )
114
+
115
+ try:
116
+ min_relevance = int(data.get("min_relevance") or data.get("minRelevance") or settings.min_relevance_threshold)
117
+ except (ValueError, TypeError):
118
+ min_relevance = settings.min_relevance_threshold
119
+
120
+ # 3. Relevance Evaluation & Hybrid Ranking
121
+ eval_repos = llm_service.evaluate_repository_relevance(idea, raw_repos, language=language)
122
+ repos = github_service.rank_and_sort_by_relevance(eval_repos, limit=limit, min_relevance=min_relevance)
123
+
124
+ # 4. Perform Gap Analysis
125
+ report = llm_service.analyze_market_and_gaps(idea, repos, language=language)
126
+
127
+ return jsonify({
128
+ "queries": queries.model_dump(),
129
+ "repositories": [r.model_dump() for r in repos],
130
+ "report": report.model_dump(),
131
+ })
132
+
133
+ except ValueError as ve:
134
+ return jsonify({"error": str(ve)}), 400
135
+ except Exception as e:
136
+ err_msg = str(e)
137
+ if any(k in err_msg.lower() for k in ["invalid_api_key", "invalid api key", "authentication", "unauthorized"]):
138
+ err_msg = "Invalid AI API Key! Please click the Settings Modal (⚙️) in the top-right corner and enter a valid API Key."
139
+ return jsonify({"error": err_msg}), 400
140
+
141
+ @app.route("/api/search", methods=["POST"])
142
+ def search_repos():
143
+ data = request.get_json() or {}
144
+ query = data.get("query", "").strip()
145
+ limit = int(data.get("limit", 10))
146
+ sort = data.get("sort", "stars")
147
+
148
+ def clean_key(v):
149
+ if not v:
150
+ return None
151
+ cleaned = str(v).replace("\r", "").replace("\n", "").replace("\t", "").strip().strip("'\"")
152
+ return cleaned if cleaned else None
153
+
154
+ github_token = clean_key(
155
+ request.headers.get("X-Github-Token")
156
+ or data.get("github_token")
157
+ or data.get("githubToken")
158
+ or settings.github_token
159
+ )
160
+
161
+ if not query:
162
+ return jsonify({"error": "Search query parameter is required."}), 400
163
+
164
+ try:
165
+ github_service = GitHubService(token=github_token)
166
+ repos = safe_run_async(github_service.search_repositories(query, limit=limit, sort=sort))
167
+ return jsonify({
168
+ "repositories": [r.model_dump() for r in repos]
169
+ })
170
+ except Exception as e:
171
+ return jsonify({"error": str(e)}), 500
172
+
173
+ return app
@@ -0,0 +1,48 @@
1
+ """
2
+ Example script demonstrating programmatic usage of GitRadar Python SDK services.
3
+ """
4
+
5
+ import asyncio
6
+ from gitradar.services.github import GitHubService
7
+ from gitradar.services.llm import LLMService
8
+ from gitradar.utils import ui
9
+
10
+
11
+ async def main():
12
+ idea = "AI powered automated documentation generator for Python repositories"
13
+ target_language = "Turkish"
14
+
15
+ print(f"📡 Analyzing Project Idea via GitRadar SDK: '{idea}' (Language: {target_language})\n")
16
+
17
+ # 1. Initialize Services with custom response language
18
+ github_service = GitHubService()
19
+ llm_service = LLMService(language=target_language)
20
+
21
+ # 2. Expand Idea into Search Strategy
22
+ print("1️⃣ Generating search queries with LLM...")
23
+ queries = llm_service.expand_idea_to_queries(idea, language=target_language)
24
+ print(f" Keywords: {queries.search_keywords}")
25
+ print(f" Topics: {queries.github_topics}\n")
26
+
27
+ # 3. Search & Enrich Repositories
28
+ print("2️⃣ Fetching GitHub repositories asynchronously...")
29
+ repos = await github_service.search_and_enrich(
30
+ keywords=queries.search_keywords,
31
+ topics=queries.github_topics,
32
+ limit=5,
33
+ )
34
+ print(f" Found {len(repos)} relevant repositories.\n")
35
+
36
+ # 4. Perform Gap Analysis & Implementation Guidance
37
+ print("3️⃣ Running Market & Gap Analysis...")
38
+ report = llm_service.analyze_market_and_gaps(idea, repos, language=target_language)
39
+
40
+ if report.implementation_guide:
41
+ print(f" Recommended Stack: {report.implementation_guide.recommended_tech_stack}")
42
+
43
+ # 5. Display with GitRadar UI
44
+ ui.display_gap_report(report)
45
+
46
+
47
+ if __name__ == "__main__":
48
+ asyncio.run(main())
gitradar/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """
2
+ GitRadar: Smart GitHub Market & Gap Analysis CLI tool.
3
+ """
4
+
5
+ import sys
6
+ import typing
7
+
8
+ # Fix compatibility issue on Python < 3.11 where third-party packages (e.g. litellm)
9
+ # attempt to import 'NotRequired' or other PEP 655/673 types directly from 'typing'.
10
+ if sys.version_info < (3, 11):
11
+ try:
12
+ import typing_extensions
13
+
14
+ for _name in ("NotRequired", "Required", "Self", "Never", "LiteralString", "TypeAlias", "Override", "dataclass_transform"):
15
+ if not hasattr(typing, _name) and hasattr(typing_extensions, _name):
16
+ setattr(typing, _name, getattr(typing_extensions, _name))
17
+ except ImportError:
18
+ pass
19
+
20
+ __version__ = "0.1.5"
21
+ __author__ = "GitRadar Developer"
22
+
gitradar/cli.py ADDED
@@ -0,0 +1,214 @@
1
+ import asyncio
2
+ import webbrowser
3
+ from typing import Optional
4
+ import typer
5
+ from rich.console import Console
6
+ from rich.status import Status
7
+
8
+ from gitradar import __version__
9
+ from gitradar.config import settings, save_setting
10
+ from gitradar.services.github import GitHubService
11
+ from gitradar.services.llm import LLMService
12
+ from gitradar.utils import ui
13
+ from gitradar.web import create_app
14
+
15
+ app = typer.Typer(
16
+ name="gitradar",
17
+ help="📡 GitRadar: GitHub Market & Gap Analysis CLI tool driven by AI & GitHub REST API.",
18
+ add_completion=False,
19
+ )
20
+ console = Console()
21
+
22
+
23
+ @app.command(name="analyze", help="💡 Analyze a project idea, scan candidate repos, and generate a Market & Gap Report.")
24
+ def analyze(
25
+ idea: str = typer.Argument(..., help="Project idea to analyze (e.g. 'AI code review tool for git hooks')"),
26
+ limit: int = typer.Option(10, "--limit", "-l", help="Maximum repositories to fetch and evaluate"),
27
+ min_relevance: int = typer.Option(50, "--min-relevance", "-r", help="Minimum relevance score threshold (0-100%)"),
28
+ model: Optional[str] = typer.Option(None, "--model", "-m", help="Override AI model (e.g. 'gpt-4o-mini', 'llama-3.3-70b-versatile')"),
29
+ api_key: Optional[str] = typer.Option(None, "--api-key", "-k", help="Override OpenAI or compatible API Key"),
30
+ base_url: Optional[str] = typer.Option(None, "--base-url", "-u", help="Override OpenAI-compatible base URL"),
31
+ language: Optional[str] = typer.Option(None, "--lang", "--language", help="Output language for AI report (e.g. 'Turkish', 'English', 'Spanish')"),
32
+ ):
33
+ """Run full AI-driven market and gap analysis workflow on a project idea."""
34
+ ui.display_banner()
35
+
36
+ try:
37
+ llm_service = LLMService(api_key=api_key, base_url=base_url, model=model, language=language)
38
+ github_service = GitHubService()
39
+
40
+ # Step 1: Query Expansion with LLM
41
+ with Status("[bold cyan]🧠 Analyzing project idea with LLM & deriving search strategy...[/bold cyan]", spinner="dots"):
42
+ queries = llm_service.expand_idea_to_queries(idea, language=language)
43
+
44
+ ui.display_query_strategy(queries)
45
+
46
+ # Step 2: GitHub Search & Enrichment
47
+ with Status(f"[bold cyan]🔍 Scanning GitHub repositories via REST API (Max: {limit})...[/bold cyan]", spinner="earth"):
48
+ candidate_repos = asyncio.run(
49
+ github_service.search_and_enrich(
50
+ keywords=queries.search_keywords,
51
+ topics=queries.github_topics,
52
+ limit=max(limit * 2, 15),
53
+ )
54
+ )
55
+
56
+ if not candidate_repos:
57
+ ui.display_error("No relevant repositories found. Try phrasing your project idea with different terms.")
58
+ raise typer.Exit(code=1)
59
+
60
+ # Step 2b: Relevance Evaluation & Hybrid Ranking
61
+ with Status(f"[bold cyan]⚖️ Evaluating relevance match & filtering by threshold (≥{min_relevance}%)...[/bold cyan]", spinner="dots"):
62
+ evaluated_repos = llm_service.evaluate_repository_relevance(idea, candidate_repos, language=language)
63
+ repos = github_service.rank_and_sort_by_relevance(evaluated_repos, limit=limit, min_relevance=min_relevance)
64
+
65
+ ui.display_repo_table(repos, title=f"Repositories Related to '{idea}' (Threshold: ≥{min_relevance}%)")
66
+
67
+ # Step 3: Semantic Gap Analysis with LLM
68
+ with Status("[bold cyan]🎯 Semantically evaluating candidate repos & synthesizing Market & Gap Report...[/bold cyan]", spinner="bouncingBar"):
69
+ report = llm_service.analyze_market_and_gaps(idea, repos, language=language)
70
+
71
+ # Step 4: Display Final Report
72
+ ui.display_gap_report(report)
73
+
74
+ except ValueError as ve:
75
+ ui.display_error(str(ve))
76
+ raise typer.Exit(code=1)
77
+ except Exception as e:
78
+ ui.display_error(f"An unexpected error occurred: {str(e)}")
79
+ raise typer.Exit(code=1)
80
+
81
+
82
+ @app.command(name="search", help="🔍 Perform a quick standalone GitHub repository search.")
83
+ def search(
84
+ query: str = typer.Argument(..., help="Search query string or keywords"),
85
+ limit: int = typer.Option(10, "--limit", "-l", help="Maximum repositories to return"),
86
+ sort: str = typer.Option("stars", "--sort", "-s", help="Sort field (stars, forks, updated)"),
87
+ ):
88
+ """Fast standalone repository search without LLM synthesis."""
89
+ ui.display_banner()
90
+ github_service = GitHubService()
91
+
92
+ try:
93
+ with Status(f"[bold cyan]🔍 Searching GitHub for '{query}'...[/bold cyan]", spinner="dots"):
94
+ repos = asyncio.run(github_service.search_repositories(query, limit=limit, sort=sort))
95
+
96
+ ui.display_repo_table(repos, title=f"Search Results: '{query}'")
97
+
98
+ except Exception as e:
99
+ ui.display_error(f"GitHub Search Failed: {str(e)}")
100
+ raise typer.Exit(code=1)
101
+
102
+
103
+ @app.command(name="ui", help="🌐 Launches local GitRadar web dashboard in your browser.")
104
+ def ui_command(
105
+ port: int = typer.Option(5000, "--port", "-p", help="Port to run the local web server on"),
106
+ host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host address to bind the web server"),
107
+ open_browser: bool = typer.Option(True, "--open/--no-open", help="Automatically open browser tab"),
108
+ ):
109
+ """Launch local Flask web application dashboard."""
110
+ ui.display_banner()
111
+ url = f"http://{host}:{port}"
112
+ console.print(f"[bold green]🚀 Launching GitRadar Web Dashboard at:[/bold green] [bold cyan]{url}[/bold cyan]")
113
+
114
+ if open_browser:
115
+ try:
116
+ webbrowser.open(url)
117
+ except Exception:
118
+ pass
119
+
120
+ flask_app = create_app()
121
+ flask_app.run(host=host, port=port, debug=False)
122
+
123
+
124
+ @app.command(name="config", help="⚙️ View or configure GitRadar API credentials and preferences.")
125
+ def config_command(
126
+ show: bool = typer.Option(False, "--show", help="Display current configuration"),
127
+ openai_api_key: Optional[str] = typer.Option(None, "--openai-api-key", "-k", help="Save OpenAI or compatible API Key credential"),
128
+ base_url: Optional[str] = typer.Option(None, "--base-url", "-u", help="Save custom OpenAI-compatible Base URL (e.g. https://api.groq.com/openai/v1, http://localhost:11434/v1)"),
129
+ groq_api_key: Optional[str] = typer.Option(None, "--groq-api-key", help="Save Groq API Key credential (legacy / fallback)"),
130
+ github_token: Optional[str] = typer.Option(None, "--github-token", help="Save GitHub Access Token credential"),
131
+ default_model: Optional[str] = typer.Option(None, "--model", help="Save default AI model identifier (e.g. gpt-4o-mini, llama-3.3-70b-versatile)"),
132
+ default_language: Optional[str] = typer.Option(None, "--lang", "--language", help="Save default response language for AI reports"),
133
+ ):
134
+ """Manage GitRadar configuration."""
135
+ ui.display_banner()
136
+
137
+ updated = False
138
+ if openai_api_key:
139
+ save_setting("OPENAI_API_KEY", openai_api_key)
140
+ ui.display_info("OPENAI_API_KEY successfully saved! 🔑")
141
+ updated = True
142
+
143
+ if base_url:
144
+ save_setting("OPENAI_BASE_URL", base_url)
145
+ ui.display_info(f"OPENAI_BASE_URL saved as '{base_url}'! 🌐")
146
+ updated = True
147
+
148
+ if groq_api_key:
149
+ save_setting("GROQ_API_KEY", groq_api_key)
150
+ ui.display_info("GROQ_API_KEY successfully saved! 🔑")
151
+ updated = True
152
+
153
+ if github_token:
154
+ save_setting("GITHUB_TOKEN", github_token)
155
+ ui.display_info("GITHUB_TOKEN successfully saved! 🐙")
156
+ updated = True
157
+
158
+ if default_model:
159
+ save_setting("DEFAULT_MODEL", default_model)
160
+ ui.display_info(f"Default model saved as '{default_model}'! 🤖")
161
+ updated = True
162
+
163
+ if default_language:
164
+ save_setting("DEFAULT_LANGUAGE", default_language)
165
+ ui.display_info(f"Default language saved as '{default_language}'! 🌐")
166
+ updated = True
167
+
168
+ if show or not updated:
169
+ curr_settings = settings
170
+ grid = ui.Table(title="⚙️ Current GitRadar Configuration", header_style="bold yellow", expand=True)
171
+ grid.add_column("Parameter", style="bold cyan")
172
+ grid.add_column("Value", style="white")
173
+
174
+ openai_val = curr_settings.openai_api_key
175
+ masked_openai = f"{openai_val[:6]}...{openai_val[-4:]}" if openai_val and len(openai_val) > 10 else ("Configured" if openai_val else "[red]Not Configured[/red]")
176
+
177
+ groq_val = curr_settings.groq_api_key
178
+ masked_groq = f"{groq_val[:6]}...{groq_val[-4:]}" if groq_val and len(groq_val) > 10 else ("Configured" if groq_val else "[dim]Not Configured[/dim]")
179
+
180
+ base_url_val = curr_settings.openai_base_url or (
181
+ "https://api.groq.com/openai/v1 (Auto-routed from Groq Key)"
182
+ if not curr_settings.openai_api_key and curr_settings.groq_api_key
183
+ else "https://api.openai.com/v1 (Default)"
184
+ )
185
+
186
+ gh_val = curr_settings.github_token
187
+ masked_gh = f"{gh_val[:4]}...{gh_val[-4:]}" if gh_val and len(gh_val) > 8 else ("Configured (Anonymous Mode)" if not gh_val else "Configured")
188
+
189
+ grid.add_row("OPENAI_API_KEY", masked_openai)
190
+ if groq_val:
191
+ grid.add_row("GROQ_API_KEY (Legacy)", masked_groq)
192
+ grid.add_row("OPENAI_BASE_URL", base_url_val)
193
+ grid.add_row("GITHUB_TOKEN", masked_gh)
194
+ grid.add_row("DEFAULT_MODEL", curr_settings.default_model)
195
+ grid.add_row("DEFAULT_LANGUAGE", curr_settings.default_language)
196
+ grid.add_row("MAX_REPOS_TO_ANALYZE", str(curr_settings.max_repos_to_analyze))
197
+ grid.add_row("MIN_RELEVANCE_THRESHOLD", f"{curr_settings.min_relevance_threshold}%")
198
+
199
+ console.print(grid)
200
+
201
+
202
+ @app.command(name="version", help="ℹ️ Display GitRadar version information.")
203
+ def version():
204
+ """Display CLI version."""
205
+ ui.display_banner()
206
+ console.print(f"[bold cyan]GitRadar CLI Version:[/bold cyan] [bold white]v{__version__}[/bold white]")
207
+
208
+
209
+ def main():
210
+ app()
211
+
212
+
213
+ if __name__ == "__main__":
214
+ main()
gitradar/config.py ADDED
@@ -0,0 +1,117 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Optional
4
+ from pydantic import Field, field_validator
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+ CONFIG_DIR = Path.home() / ".config" / "gitradar"
8
+ CONFIG_FILE = CONFIG_DIR / "config.env"
9
+
10
+
11
+ class Settings(BaseSettings):
12
+ openai_api_key: Optional[str] = None
13
+ openai_base_url: Optional[str] = None
14
+ groq_api_key: Optional[str] = None
15
+ github_token: Optional[str] = None
16
+ default_model: str = "gpt-4o-mini"
17
+ default_language: str = "English"
18
+ max_repos_to_analyze: int = 50
19
+ min_relevance_threshold: int = 50
20
+
21
+ model_config = SettingsConfigDict(
22
+ env_file=(str(CONFIG_FILE), ".env"),
23
+ env_file_encoding="utf-8",
24
+ extra="ignore",
25
+ )
26
+
27
+ @property
28
+ def effective_api_key(self) -> Optional[str]:
29
+ return self.openai_api_key or self.groq_api_key
30
+
31
+ @property
32
+ def effective_base_url(self) -> Optional[str]:
33
+ if self.openai_base_url:
34
+ return self.openai_base_url
35
+ if not self.openai_api_key and self.groq_api_key:
36
+ return "https://api.groq.com/openai/v1"
37
+ return None
38
+
39
+ @field_validator("max_repos_to_analyze", "min_relevance_threshold", mode="before")
40
+ @classmethod
41
+ def validate_int_settings(cls, v):
42
+ if v is None or (isinstance(v, str) and v.strip() == ""):
43
+ return 50 if "min_relevance" in str(v) else 10
44
+ try:
45
+ return int(v)
46
+ except (ValueError, TypeError):
47
+ return 50
48
+
49
+ @field_validator("openai_api_key", "openai_base_url", "groq_api_key", "github_token", "default_model", "default_language", mode="before")
50
+ @classmethod
51
+ def validate_empty_strings(cls, v):
52
+ if isinstance(v, str) and v.strip() == "":
53
+ return None
54
+ return v
55
+
56
+
57
+ def load_settings() -> Settings:
58
+ """Load settings from env vars or config files safely."""
59
+ try:
60
+ s = Settings()
61
+ except Exception:
62
+ try:
63
+ s = Settings(_env_file=None)
64
+ except Exception:
65
+ s = Settings.model_construct(
66
+ openai_api_key=None,
67
+ openai_base_url=None,
68
+ groq_api_key=None,
69
+ github_token=None,
70
+ default_model="gpt-4o-mini",
71
+ default_language="English",
72
+ max_repos_to_analyze=50,
73
+ min_relevance_threshold=50,
74
+ )
75
+
76
+ if not s.openai_base_url:
77
+ s.openai_base_url = os.environ.get("OPENAI_BASE_URL") or os.environ.get("OPENAI_API_BASE")
78
+ if not s.openai_api_key:
79
+ s.openai_api_key = os.environ.get("OPENAI_API_KEY")
80
+ if s.default_model == "gpt-4o-mini" and os.environ.get("OPENAI_MODEL"):
81
+ s.default_model = os.environ["OPENAI_MODEL"]
82
+ return s
83
+
84
+
85
+ def save_setting(key: str, value: str) -> None:
86
+ """Save a setting key-value pair to user config file (~/.config/gitradar/config.env)."""
87
+ try:
88
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
89
+
90
+ existing_lines = []
91
+ if CONFIG_FILE.exists():
92
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
93
+ existing_lines = f.readlines()
94
+
95
+ key_upper = key.upper()
96
+ found = False
97
+ new_lines = []
98
+
99
+ for line in existing_lines:
100
+ if line.strip().startswith(f"{key_upper}="):
101
+ new_lines.append(f"{key_upper}={value}\n")
102
+ found = True
103
+ else:
104
+ new_lines.append(line)
105
+
106
+ if not found:
107
+ if new_lines and not new_lines[-1].endswith("\n"):
108
+ new_lines.append("\n")
109
+ new_lines.append(f"{key_upper}={value}\n")
110
+
111
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
112
+ f.writelines(new_lines)
113
+ except Exception:
114
+ pass
115
+
116
+
117
+ settings = load_settings()
gitradar/models.py ADDED
@@ -0,0 +1,67 @@
1
+ from typing import Optional, List
2
+ from pydantic import BaseModel, Field
3
+
4
+
5
+ class RepositoryInfo(BaseModel):
6
+ """Model representing a GitHub repository summary."""
7
+ full_name: str
8
+ name: str
9
+ owner: str
10
+ html_url: str
11
+ description: Optional[str] = "No description provided"
12
+ stars: int = 0
13
+ forks: int = 0
14
+ language: Optional[str] = "Unspecified"
15
+ topics: List[str] = Field(default_factory=list)
16
+ updated_at: Optional[str] = ""
17
+ open_issues: int = 0
18
+ readme_snippet: Optional[str] = None
19
+ relevance_score: Optional[int] = Field(default=None, description="LLM evaluated relevance score 0-100%")
20
+ is_direct_competitor: Optional[bool] = Field(default=True, description="Whether repo directly addresses user idea concept")
21
+ relevance_reason: Optional[str] = Field(default=None, description="Brief explanation of relevance fit")
22
+ hybrid_score: Optional[float] = Field(default=0.0, description="Calculated hybrid ranking score")
23
+
24
+
25
+ class ExpandedQueries(BaseModel):
26
+ """Model for LLM generated search strategy."""
27
+ search_keywords: List[str] = Field(default_factory=list, description="Optimized search keywords for GitHub REST API")
28
+ github_topics: List[str] = Field(default_factory=list, description="Relevant GitHub topics/tags")
29
+ target_languages: List[str] = Field(default_factory=list, description="Suggested or target programming languages")
30
+ search_explanation: str = Field(default="Search strategy generated.", description="Explanation of the search strategy")
31
+
32
+
33
+ class CompetitorSummary(BaseModel):
34
+ """Model for a key competitor identified in gap analysis."""
35
+ repo_name: str = Field(default="Unknown Repository")
36
+ key_strengths: List[str] = Field(default_factory=list)
37
+ weaknesses_or_gaps: List[str] = Field(default_factory=list)
38
+
39
+
40
+ class OpenSourceTool(BaseModel):
41
+ """Model representing a recommended open-source library or tool to build the project."""
42
+ name: str = Field(default="Open-Source Tool", description="Name of the open-source tool or library (e.g. Typer, Qdrant, Tree-sitter)")
43
+ category: str = Field(default="General", description="Category (e.g. CLI Framework, Vector DB, LLM SDK, Parser)")
44
+ description_and_usage: str = Field(default="", description="How to leverage this tool in building the project idea")
45
+ repo_url: Optional[str] = Field(default=None, description="GitHub repository or URL link if applicable")
46
+
47
+
48
+ class ImplementationGuide(BaseModel):
49
+ """Model for architecture and technical implementation guidance."""
50
+ recommended_tech_stack: List[str] = Field(default_factory=list, description="Recommended technology stack (languages, frameworks, DBs)")
51
+ architecture_overview: str = Field(default="", description="High-level architecture and how the system should be structured")
52
+ open_source_building_blocks: List[OpenSourceTool] = Field(default_factory=list, description="Key open-source tools to use during development")
53
+
54
+
55
+ class GapAnalysisReport(BaseModel):
56
+ """Model for LLM generated Market and Gap Analysis Report."""
57
+ idea_summary: str = Field(default="Project Idea Summary", description="Summary of the analyzed project idea")
58
+ market_saturation: str = Field(default="Moderate", description="Market Saturation level: Low, Moderate, or High")
59
+ saturation_score: int = Field(default=50, description="Market saturation score from 1-100")
60
+ market_summary: str = Field(default="Market analysis completed.", description="Overview of the current market and ecosystem")
61
+ top_competitors: List[CompetitorSummary] = Field(default_factory=list, description="Top competitor repositories and analysis")
62
+ unmet_needs: List[str] = Field(default_factory=list, description="Unmet needs and open gaps identified in existing repos")
63
+ differentiators: List[str] = Field(default_factory=list, description="Key differentiators to make your project stand out")
64
+ actionable_recommendations: List[str] = Field(default_factory=list, description="Actionable recommendations for the developer")
65
+ opportunity_score: int = Field(default=80, description="Opportunity potential score from 1-100")
66
+ implementation_guide: Optional[ImplementationGuide] = Field(default=None, description="Architecture, tech stack, and open source building blocks")
67
+
@@ -0,0 +1,18 @@
1
+ from pathlib import Path
2
+ from jinja2 import Environment, FileSystemLoader
3
+
4
+ PROMPTS_DIR = Path(__file__).parent
5
+
6
+ env = Environment(
7
+ loader=FileSystemLoader(PROMPTS_DIR),
8
+ trim_blocks=True,
9
+ lstrip_blocks=True,
10
+ )
11
+
12
+
13
+ def render_prompt(template_name: str, **kwargs) -> str:
14
+ """Render a Jinja2 prompt template with the provided context variables."""
15
+ if not template_name.endswith(".j2"):
16
+ template_name += ".j2"
17
+ template = env.get_template(template_name)
18
+ return template.render(**kwargs)