akitallm 0.1.1__py3-none-any.whl → 1.1.0__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.
- akita/__init__.py +1 -1
- akita/cli/main.py +153 -24
- akita/core/ast_utils.py +77 -0
- akita/core/config.py +12 -2
- akita/core/indexing.py +94 -0
- akita/core/plugins.py +81 -0
- akita/core/providers.py +181 -0
- akita/core/trace.py +18 -0
- akita/models/base.py +12 -7
- akita/plugins/__init__.py +1 -0
- akita/plugins/files.py +34 -0
- akita/reasoning/engine.py +44 -18
- akita/reasoning/session.py +15 -0
- akita/tools/base.py +6 -1
- akita/tools/context.py +54 -9
- akita/tools/diff.py +100 -25
- akita/tools/git.py +79 -0
- {akitallm-0.1.1.dist-info → akitallm-1.1.0.dist-info}/METADATA +8 -11
- akitallm-1.1.0.dist-info/RECORD +24 -0
- akitallm-1.1.0.dist-info/entry_points.txt +5 -0
- akitallm-0.1.1.dist-info/RECORD +0 -15
- akitallm-0.1.1.dist-info/entry_points.txt +0 -2
- {akitallm-0.1.1.dist-info → akitallm-1.1.0.dist-info}/WHEEL +0 -0
- {akitallm-0.1.1.dist-info → akitallm-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {akitallm-0.1.1.dist-info → akitallm-1.1.0.dist-info}/top_level.txt +0 -0
akita/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "
|
|
1
|
+
__version__ = "1.1.0"
|
akita/cli/main.py
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import typer
|
|
2
|
+
from typing import Optional, List, Dict, Any
|
|
2
3
|
from rich.console import Console
|
|
3
4
|
from rich.panel import Panel
|
|
4
5
|
from akita.reasoning.engine import ReasoningEngine
|
|
6
|
+
from akita.core.indexing import CodeIndexer
|
|
5
7
|
from akita.models.base import get_model
|
|
6
8
|
from akita.core.config import load_config, save_config, reset_config, CONFIG_FILE
|
|
7
9
|
from rich.table import Table
|
|
8
10
|
from rich.markdown import Markdown
|
|
9
11
|
from rich.syntax import Syntax
|
|
10
12
|
from dotenv import load_dotenv
|
|
13
|
+
from akita.tools.diff import DiffApplier
|
|
14
|
+
from akita.tools.git import GitTool
|
|
15
|
+
from akita.core.providers import detect_provider
|
|
11
16
|
|
|
12
17
|
# Load environment variables from .env file
|
|
13
18
|
load_dotenv()
|
|
@@ -40,27 +45,65 @@ def main(
|
|
|
40
45
|
|
|
41
46
|
def run_onboarding():
|
|
42
47
|
console.print(Panel(
|
|
43
|
-
"[bold cyan]AkitaLLM[/]\n\n[italic]
|
|
48
|
+
"[bold cyan]AkitaLLM Configuration[/]\n\n[italic]API-first setup...[/]",
|
|
44
49
|
title="Onboarding"
|
|
45
50
|
))
|
|
46
51
|
|
|
47
|
-
|
|
48
|
-
console.print("2) Configure my own model")
|
|
52
|
+
api_key = typer.prompt("🔑 Paste your API Key (or type 'ollama' for local)", hide_input=False)
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
provider = detect_provider(api_key)
|
|
55
|
+
if not provider:
|
|
56
|
+
console.print("[bold red]❌ Could not detect provider from the given key.[/]")
|
|
57
|
+
console.print("Make sure you are using a valid OpenAI (sk-...) or Anthropic (sk-ant-...) key.")
|
|
58
|
+
raise typer.Abort()
|
|
59
|
+
|
|
60
|
+
console.print(f"[bold green]✅ Detected Provider:[/] {provider.name.upper()}")
|
|
61
|
+
|
|
62
|
+
with console.status(f"[bold blue]Consulting {provider.name} API for available models..."):
|
|
63
|
+
try:
|
|
64
|
+
models = provider.list_models(api_key)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
console.print(f"[bold red]❌ Failed to list models:[/] {e}")
|
|
67
|
+
raise typer.Abort()
|
|
51
68
|
|
|
52
|
-
if
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
69
|
+
if not models:
|
|
70
|
+
console.print("[bold yellow]⚠️ No models found for this provider.[/]")
|
|
71
|
+
raise typer.Abort()
|
|
72
|
+
|
|
73
|
+
console.print("\n[bold]Select a model:[/]")
|
|
74
|
+
for i, model in enumerate(models):
|
|
75
|
+
name_display = f" ({model.name})" if model.name else ""
|
|
76
|
+
console.print(f"{i+1}) [cyan]{model.id}[/]{name_display}")
|
|
77
|
+
|
|
78
|
+
choice = typer.prompt("\nChoose a model number", type=int, default=1)
|
|
79
|
+
if 1 <= choice <= len(models):
|
|
80
|
+
selected_model = models[choice-1].id
|
|
56
81
|
else:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
82
|
+
console.print("[bold red]Invalid choice.[/]")
|
|
83
|
+
raise typer.Abort()
|
|
84
|
+
|
|
85
|
+
# Determine if we should save the key or use an env ref
|
|
86
|
+
use_env = typer.confirm("Would you like to use an environment variable for the API key? (Recommended)", default=True)
|
|
62
87
|
|
|
63
|
-
|
|
88
|
+
final_key_ref = api_key
|
|
89
|
+
if use_env and provider.name != "ollama":
|
|
90
|
+
env_var_name = f"{provider.name.upper()}_API_KEY"
|
|
91
|
+
console.print(f"[dim]Please ensure you set [bold]{env_var_name}[/] in your .env or shell.[/]")
|
|
92
|
+
final_key_ref = f"env:{env_var_name}"
|
|
93
|
+
|
|
94
|
+
config = {
|
|
95
|
+
"model": {
|
|
96
|
+
"provider": provider.name,
|
|
97
|
+
"name": selected_model,
|
|
98
|
+
"api_key": final_key_ref
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
save_config(config)
|
|
103
|
+
console.print(f"\n[bold green]✨ Configuration saved![/]")
|
|
104
|
+
console.print(f"Model: [bold]{selected_model}[/]")
|
|
105
|
+
console.print(f"Key reference: [dim]{final_key_ref}[/]")
|
|
106
|
+
console.print("\n[dim]Configuration stored at ~/.akita/config.toml[/]\n")
|
|
64
107
|
|
|
65
108
|
@app.command()
|
|
66
109
|
def review(
|
|
@@ -119,27 +162,53 @@ def review(
|
|
|
119
162
|
@app.command()
|
|
120
163
|
def solve(
|
|
121
164
|
query: str,
|
|
165
|
+
interactive: bool = typer.Option(False, "--interactive", "-i", help="Run in interactive mode to refine the solution."),
|
|
166
|
+
trace: bool = typer.Option(False, "--trace", help="Show the internal reasoning trace."),
|
|
122
167
|
dry_run: bool = typer.Option(False, "--dry-run", help="Run in dry-run mode.")
|
|
123
168
|
):
|
|
124
169
|
"""
|
|
125
|
-
Generate a solution for the given query.
|
|
170
|
+
Generate and apply a solution for the given query.
|
|
126
171
|
"""
|
|
127
172
|
model = get_model()
|
|
128
173
|
engine = ReasoningEngine(model)
|
|
129
174
|
console.print(Panel(f"[bold blue]Akita[/] is thinking about: [italic]{query}[/]", title="Solve Mode"))
|
|
130
175
|
|
|
176
|
+
current_query = query
|
|
177
|
+
session = None
|
|
178
|
+
|
|
131
179
|
try:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
180
|
+
while True:
|
|
181
|
+
diff_output = engine.run_solve(current_query, session=session)
|
|
182
|
+
session = engine.session
|
|
183
|
+
|
|
184
|
+
if trace:
|
|
185
|
+
console.print(Panel(str(engine.trace), title="[bold cyan]Reasoning Trace[/]", border_style="cyan"))
|
|
186
|
+
console.print(Panel("[bold green]Suggested Code Changes (Unified Diff):[/]"))
|
|
187
|
+
syntax = Syntax(diff_output, "diff", theme="monokai", line_numbers=True)
|
|
188
|
+
console.print(syntax)
|
|
189
|
+
|
|
190
|
+
if interactive:
|
|
191
|
+
action = typer.prompt("\n[A]pprove, [R]efine with feedback, or [C]ancel?", default="A").upper()
|
|
192
|
+
if action == "A":
|
|
193
|
+
break
|
|
194
|
+
elif action == "R":
|
|
195
|
+
current_query = typer.prompt("Enter your feedback/refinement")
|
|
196
|
+
continue
|
|
197
|
+
else:
|
|
198
|
+
console.print("[yellow]Operation cancelled.[/]")
|
|
199
|
+
return
|
|
200
|
+
else:
|
|
201
|
+
break
|
|
202
|
+
|
|
138
203
|
if not dry_run:
|
|
139
204
|
confirm = typer.confirm("\nDo you want to apply these changes?")
|
|
140
205
|
if confirm:
|
|
141
|
-
console.print("[bold yellow]Applying changes...
|
|
142
|
-
|
|
206
|
+
console.print("[bold yellow]🚀 Applying changes...[/]")
|
|
207
|
+
success = DiffApplier.apply_unified_diff(diff_output)
|
|
208
|
+
if success:
|
|
209
|
+
console.print("[bold green]✅ Changes applied successfully![/]")
|
|
210
|
+
else:
|
|
211
|
+
console.print("[bold red]❌ Failed to apply changes.[/]")
|
|
143
212
|
else:
|
|
144
213
|
console.print("[bold yellow]Changes discarded.[/]")
|
|
145
214
|
except Exception as e:
|
|
@@ -165,12 +234,52 @@ def plan(
|
|
|
165
234
|
console.print(f"[bold red]Planning failed:[/] {e}")
|
|
166
235
|
raise typer.Exit(code=1)
|
|
167
236
|
|
|
237
|
+
@app.command()
|
|
238
|
+
def clone(
|
|
239
|
+
url: str = typer.Argument(..., help="Git repository URL to clone."),
|
|
240
|
+
branch: Optional[str] = typer.Option(None, "--branch", "-b", help="Specific branch to clone."),
|
|
241
|
+
depth: Optional[int] = typer.Option(None, "--depth", "-d", help="Create a shallow clone with a history truncated to the specified number of commits.")
|
|
242
|
+
):
|
|
243
|
+
"""
|
|
244
|
+
Clone a remote Git repository into the Akita workspace (~/.akita/repos/).
|
|
245
|
+
"""
|
|
246
|
+
console.print(Panel(f"🌐 [bold blue]Akita[/] is cloning: [yellow]{url}[/]", title="Clone Mode"))
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
with console.status("[bold green]Cloning repository..."):
|
|
250
|
+
local_path = GitTool.clone_repo(url, branch=branch, depth=depth)
|
|
251
|
+
|
|
252
|
+
console.print(f"\n[bold green]✅ Repository cloned successfully![/]")
|
|
253
|
+
console.print(f"📍 Local path: [cyan]{local_path}[/]")
|
|
254
|
+
except FileExistsError as e:
|
|
255
|
+
console.print(f"[bold yellow]⚠️ {e}[/]")
|
|
256
|
+
except Exception as e:
|
|
257
|
+
console.print(f"[bold red]❌ Clone failed:[/] {e}")
|
|
258
|
+
raise typer.Exit(code=1)
|
|
259
|
+
|
|
260
|
+
@app.command()
|
|
261
|
+
def index(
|
|
262
|
+
path: str = typer.Argument(".", help="Path to index for RAG.")
|
|
263
|
+
):
|
|
264
|
+
"""
|
|
265
|
+
Build a local vector index (RAG) for the project.
|
|
266
|
+
"""
|
|
267
|
+
console.print(Panel(f"🔍 [bold blue]Akita[/] is indexing: [yellow]{path}[/]", title="Index Mode"))
|
|
268
|
+
try:
|
|
269
|
+
indexer = CodeIndexer(path)
|
|
270
|
+
with console.status("[bold green]Indexing project files..."):
|
|
271
|
+
indexer.index_project()
|
|
272
|
+
console.print("[bold green]✅ Indexing complete! Semantic search is now active.[/]")
|
|
273
|
+
except Exception as e:
|
|
274
|
+
console.print(f"[bold red]Indexing failed:[/] {e}")
|
|
275
|
+
raise typer.Exit(code=1)
|
|
276
|
+
|
|
168
277
|
@app.command()
|
|
169
278
|
def test():
|
|
170
279
|
"""
|
|
171
280
|
Run automated tests in the project.
|
|
172
281
|
"""
|
|
173
|
-
console.print(Panel("
|
|
282
|
+
console.print(Panel("[bold blue]Akita[/] is running tests...", title="Test Mode"))
|
|
174
283
|
from akita.tools.base import ShellTools
|
|
175
284
|
result = ShellTools.execute("pytest")
|
|
176
285
|
if result.success:
|
|
@@ -180,6 +289,26 @@ def test():
|
|
|
180
289
|
console.print("[bold red]Tests failed![/]")
|
|
181
290
|
console.print(result.error or result.output)
|
|
182
291
|
|
|
292
|
+
@app.command()
|
|
293
|
+
def docs():
|
|
294
|
+
"""
|
|
295
|
+
Start the local documentation server.
|
|
296
|
+
"""
|
|
297
|
+
import subprocess
|
|
298
|
+
import sys
|
|
299
|
+
|
|
300
|
+
console.print(Panel("[bold blue]Akita[/] Documentation", title="Docs Mode"))
|
|
301
|
+
console.print("[dim]Starting MkDocs server...[/]")
|
|
302
|
+
console.print("[bold green]Open your browser at: http://127.0.0.1:8000[/]")
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
subprocess.run([sys.executable, "-m", "mkdocs", "serve"], check=True)
|
|
306
|
+
except FileNotFoundError:
|
|
307
|
+
console.print("[red]MkDocs not found. Install it with: pip install mkdocs-material[/]")
|
|
308
|
+
raise typer.Exit(code=1)
|
|
309
|
+
except KeyboardInterrupt:
|
|
310
|
+
console.print("[yellow]Documentation server stopped.[/]")
|
|
311
|
+
|
|
183
312
|
# Config Command Group
|
|
184
313
|
config_app = typer.Typer(help="Manage AkitaLLM configuration.")
|
|
185
314
|
app.add_typer(config_app, name="config")
|
akita/core/ast_utils.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import tree_sitter_python as tspython
|
|
2
|
+
from tree_sitter import Language, Parser
|
|
3
|
+
import pathlib
|
|
4
|
+
from typing import List, Dict, Any, Optional
|
|
5
|
+
|
|
6
|
+
class ASTParser:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self.language = Language(tspython.language())
|
|
9
|
+
self.parser = Parser(self.language)
|
|
10
|
+
|
|
11
|
+
def parse_file(self, file_path: str) -> Optional[Any]:
|
|
12
|
+
path = pathlib.Path(file_path)
|
|
13
|
+
if not path.exists():
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
with open(path, "rb") as f:
|
|
17
|
+
content = f.read()
|
|
18
|
+
|
|
19
|
+
return self.parser.parse(content)
|
|
20
|
+
|
|
21
|
+
def get_definitions(self, file_path: str) -> List[Dict[str, Any]]:
|
|
22
|
+
"""Extract classes and functions with their line ranges using recursive traversal."""
|
|
23
|
+
tree = self.parse_file(file_path)
|
|
24
|
+
if not tree:
|
|
25
|
+
return []
|
|
26
|
+
|
|
27
|
+
with open(file_path, "rb") as f:
|
|
28
|
+
content = f.read()
|
|
29
|
+
|
|
30
|
+
definitions = []
|
|
31
|
+
|
|
32
|
+
def explore(node):
|
|
33
|
+
# Check for definitions
|
|
34
|
+
if node.type in ["class_definition", "function_definition", "decorated_definition"]:
|
|
35
|
+
# Find name
|
|
36
|
+
name_node = node.child_by_field_name("name")
|
|
37
|
+
if not name_node and node.type == "decorated_definition":
|
|
38
|
+
# For decorated definitions, the name is in the class/function child
|
|
39
|
+
inner = node.child_by_field_name("definition")
|
|
40
|
+
if inner:
|
|
41
|
+
name_node = inner.child_by_field_name("name")
|
|
42
|
+
|
|
43
|
+
name = content[name_node.start_byte:name_node.end_byte].decode("utf-8") if name_node else "anonymous"
|
|
44
|
+
|
|
45
|
+
# Docstring extraction
|
|
46
|
+
docstring = None
|
|
47
|
+
body = node.child_by_field_name("body")
|
|
48
|
+
if body and body.children:
|
|
49
|
+
for stmt in body.children:
|
|
50
|
+
if stmt.type == "expression_statement":
|
|
51
|
+
child = stmt.children[0]
|
|
52
|
+
if child.type == "string":
|
|
53
|
+
docstring = content[child.start_byte:child.end_byte].decode("utf-8").strip('"\' \n')
|
|
54
|
+
break # Only first statement
|
|
55
|
+
|
|
56
|
+
definitions.append({
|
|
57
|
+
"name": name,
|
|
58
|
+
"type": "class" if "class" in node.type else "function",
|
|
59
|
+
"start_line": node.start_point[0] + 1,
|
|
60
|
+
"end_line": node.end_point[0] + 1,
|
|
61
|
+
"docstring": docstring
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
# Always explore children regardless of current node type
|
|
65
|
+
for child in node.children:
|
|
66
|
+
explore(child)
|
|
67
|
+
|
|
68
|
+
explore(tree.root_node)
|
|
69
|
+
return definitions
|
|
70
|
+
|
|
71
|
+
def get_source_segment(self, file_path: str, start_line: int, end_line: int) -> str:
|
|
72
|
+
"""Extract a segment of code from a file by line numbers."""
|
|
73
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
74
|
+
lines = f.readlines()
|
|
75
|
+
|
|
76
|
+
# Lines are 1-indexed in our definitions, but 0-indexed in the list
|
|
77
|
+
return "".join(lines[start_line-1 : end_line])
|
akita/core/config.py
CHANGED
|
@@ -43,9 +43,19 @@ def reset_config():
|
|
|
43
43
|
if CONFIG_FILE.exists():
|
|
44
44
|
CONFIG_FILE.unlink()
|
|
45
45
|
|
|
46
|
+
def resolve_config_value(value: Any) -> Any:
|
|
47
|
+
"""
|
|
48
|
+
Resolves values like 'env:VAR_NAME' to their environment variable content.
|
|
49
|
+
"""
|
|
50
|
+
if isinstance(value, str) and value.startswith("env:"):
|
|
51
|
+
env_var = value[4:]
|
|
52
|
+
return os.getenv(env_var, value)
|
|
53
|
+
return value
|
|
54
|
+
|
|
46
55
|
def get_config_value(section: str, key: str, default: Any = None) -> Any:
|
|
47
|
-
"""Get a specific value from the config."""
|
|
56
|
+
"""Get a specific value from the config and resolve env refs."""
|
|
48
57
|
config = load_config()
|
|
49
58
|
if not config:
|
|
50
59
|
return default
|
|
51
|
-
|
|
60
|
+
val = config.get(section, {}).get(key, default)
|
|
61
|
+
return resolve_config_value(val)
|
akita/core/indexing.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Dict, Any, Optional
|
|
6
|
+
from akita.core.ast_utils import ASTParser
|
|
7
|
+
|
|
8
|
+
class CodeIndexer:
|
|
9
|
+
"""
|
|
10
|
+
A lightweight, zero-dependency semantic-keyword indexer.
|
|
11
|
+
Uses basic TF-IDF principles and AST-aware keyword weighting.
|
|
12
|
+
Works perfectly even in restricted environments like Python 3.14.
|
|
13
|
+
"""
|
|
14
|
+
def __init__(self, project_path: str):
|
|
15
|
+
self.project_path = Path(project_path)
|
|
16
|
+
self.index_file = self.project_path / ".akita" / "index.json"
|
|
17
|
+
self.ast_parser = ASTParser()
|
|
18
|
+
self.data: List[Dict[str, Any]] = []
|
|
19
|
+
self.load_index()
|
|
20
|
+
|
|
21
|
+
def load_index(self):
|
|
22
|
+
if self.index_file.exists():
|
|
23
|
+
try:
|
|
24
|
+
with open(self.index_file, "r", encoding="utf-8") as f:
|
|
25
|
+
self.data = json.load(f)
|
|
26
|
+
except Exception:
|
|
27
|
+
self.data = []
|
|
28
|
+
|
|
29
|
+
def save_index(self):
|
|
30
|
+
self.index_file.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
with open(self.index_file, "w", encoding="utf-8") as f:
|
|
32
|
+
json.dump(self.data, f, indent=2)
|
|
33
|
+
|
|
34
|
+
def index_project(self):
|
|
35
|
+
"""Index all Python files in the project."""
|
|
36
|
+
self.data = []
|
|
37
|
+
for root, _, files in os.walk(self.project_path):
|
|
38
|
+
if ".akita" in root or ".git" in root or "__pycache__" in root:
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
for file in files:
|
|
42
|
+
if file.endswith(".py"):
|
|
43
|
+
full_path = Path(root) / file
|
|
44
|
+
rel_path = full_path.relative_to(self.project_path)
|
|
45
|
+
self._index_file(full_path, str(rel_path))
|
|
46
|
+
self.save_index()
|
|
47
|
+
|
|
48
|
+
def _index_file(self, file_path: Path, rel_path: str):
|
|
49
|
+
try:
|
|
50
|
+
definitions = self.ast_parser.get_definitions(str(file_path))
|
|
51
|
+
for d in definitions:
|
|
52
|
+
source = self.ast_parser.get_source_segment(
|
|
53
|
+
str(file_path), d["start_line"], d["end_line"]
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Create a searchable representation (keyword rich)
|
|
57
|
+
# We normalize case and extract meaningful words
|
|
58
|
+
search_blob = f"{d['name']} {d['type']} {d['docstring'] or ''} {source}"
|
|
59
|
+
keywords = set(re.findall(r'\w+', search_blob.lower()))
|
|
60
|
+
|
|
61
|
+
self.data.append({
|
|
62
|
+
"path": rel_path,
|
|
63
|
+
"name": d["name"],
|
|
64
|
+
"type": d["type"],
|
|
65
|
+
"start_line": d["start_line"],
|
|
66
|
+
"end_line": d["end_line"],
|
|
67
|
+
"keywords": list(keywords),
|
|
68
|
+
"content": source[:500] # Store snippet preview
|
|
69
|
+
})
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
def search(self, query: str, n_results: int = 5) -> List[Dict[str, Any]]:
|
|
74
|
+
"""Search using Jaccard Similarity on keywords (Lite Contextual Search)."""
|
|
75
|
+
query_keywords = set(re.findall(r'\w+', query.lower()))
|
|
76
|
+
if not query_keywords:
|
|
77
|
+
return []
|
|
78
|
+
|
|
79
|
+
scores = []
|
|
80
|
+
for item in self.data:
|
|
81
|
+
item_keywords = set(item["keywords"])
|
|
82
|
+
intersection = query_keywords.intersection(item_keywords)
|
|
83
|
+
# Simple intersection count as score, weighted by name match
|
|
84
|
+
score = len(intersection)
|
|
85
|
+
if any(qk in item["name"].lower() for qk in query_keywords):
|
|
86
|
+
score += 5 # Boost explicit name matches
|
|
87
|
+
|
|
88
|
+
if score > 0:
|
|
89
|
+
scores.append((score, item))
|
|
90
|
+
|
|
91
|
+
# Sort by score descending
|
|
92
|
+
scores.sort(key=lambda x: x[0], reverse=True)
|
|
93
|
+
|
|
94
|
+
return [s[1] for s in scores[:n_results]]
|
akita/core/plugins.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import importlib
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
import inspect
|
|
5
|
+
import pkgutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Dict, Any, Type, Optional
|
|
8
|
+
|
|
9
|
+
class AkitaPlugin(abc.ABC):
|
|
10
|
+
"""Base class for all AkitaLLM plugins."""
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
@abc.abstractmethod
|
|
14
|
+
def name(self) -> str:
|
|
15
|
+
"""Unique name of the plugin."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
@abc.abstractmethod
|
|
20
|
+
def description(self) -> str:
|
|
21
|
+
"""Brief description of what the plugin does."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
@abc.abstractmethod
|
|
25
|
+
def get_tools(self) -> List[Dict[str, Any]]:
|
|
26
|
+
"""Return a list of tools (functions) provided by this plugin."""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
class PluginManager:
|
|
30
|
+
def __init__(self, internal_plugins_path: Optional[str] = None):
|
|
31
|
+
self.plugins: Dict[str, AkitaPlugin] = {}
|
|
32
|
+
self.internal_path = internal_plugins_path or str(Path(__file__).parent.parent / "plugins")
|
|
33
|
+
|
|
34
|
+
def discover_all(self):
|
|
35
|
+
"""Discover both internal and external plugins."""
|
|
36
|
+
self._discover_internal()
|
|
37
|
+
self._discover_external()
|
|
38
|
+
|
|
39
|
+
def _discover_internal(self):
|
|
40
|
+
"""Load plugins from the akita/plugins directory."""
|
|
41
|
+
path = Path(self.internal_path)
|
|
42
|
+
if not path.exists():
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
for loader, module_name, is_pkg in pkgutil.iter_modules([str(path)]):
|
|
46
|
+
full_module_name = f"akita.plugins.{module_name}"
|
|
47
|
+
try:
|
|
48
|
+
module = importlib.import_module(full_module_name)
|
|
49
|
+
self._load_from_module(module)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
print(f"Error loading internal plugin {module_name}: {e}")
|
|
52
|
+
|
|
53
|
+
def _discover_external(self):
|
|
54
|
+
"""Load plugins registered via entry_points (akitallm.plugins)."""
|
|
55
|
+
try:
|
|
56
|
+
# Python 3.10+
|
|
57
|
+
eps = importlib.metadata.entry_points(group='akitallm.plugins')
|
|
58
|
+
for entry_point in eps:
|
|
59
|
+
try:
|
|
60
|
+
plugin_class = entry_point.load()
|
|
61
|
+
if inspect.isclass(plugin_class) and issubclass(plugin_class, AkitaPlugin):
|
|
62
|
+
instance = plugin_class()
|
|
63
|
+
self.plugins[instance.name] = instance
|
|
64
|
+
except Exception as e:
|
|
65
|
+
print(f"Error loading external plugin {entry_point.name}: {e}")
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
def _load_from_module(self, module):
|
|
70
|
+
"""Extract AkitaPlugin classes from a module and instantiate them."""
|
|
71
|
+
for name, obj in inspect.getmembers(module):
|
|
72
|
+
if inspect.isclass(obj) and issubclass(obj, AkitaPlugin) and obj is not AkitaPlugin:
|
|
73
|
+
instance = obj()
|
|
74
|
+
self.plugins[instance.name] = instance
|
|
75
|
+
|
|
76
|
+
def get_all_tools(self) -> List[Dict[str, Any]]:
|
|
77
|
+
"""Collect all tools from all loaded plugins."""
|
|
78
|
+
all_tools = []
|
|
79
|
+
for plugin in self.plugins.values():
|
|
80
|
+
all_tools.extend(plugin.get_tools())
|
|
81
|
+
return all_tools
|