smart-commit-cli 0.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.
- smart_commit/__init__.py +6 -0
- smart_commit/ai/__init__.py +7 -0
- smart_commit/ai/client.py +123 -0
- smart_commit/ai/reviewer.py +91 -0
- smart_commit/ai.py +136 -0
- smart_commit/analysis/__init__.py +7 -0
- smart_commit/analysis/ast_parser.py +173 -0
- smart_commit/analysis/dependency_graph.py +114 -0
- smart_commit/analysis/diff_parser.py +172 -0
- smart_commit/analysis/summarizer.py +101 -0
- smart_commit/analysis/symbol_extractor.py +88 -0
- smart_commit/benchmark.py +88 -0
- smart_commit/cache.py +65 -0
- smart_commit/cli.py +391 -0
- smart_commit/clustering/__init__.py +7 -0
- smart_commit/clustering/graph_clustering.py +78 -0
- smart_commit/clustering/heuristic.py +178 -0
- smart_commit/clustering/semantic.py +201 -0
- smart_commit/clustering.py +186 -0
- smart_commit/commit_messages.py +232 -0
- smart_commit/committer.py +91 -0
- smart_commit/config.py +88 -0
- smart_commit/constants.py +92 -0
- smart_commit/diff_parser.py +158 -0
- smart_commit/embeddings/__init__.py +7 -0
- smart_commit/embeddings/generator.py +128 -0
- smart_commit/embeddings/similarity.py +159 -0
- smart_commit/embeddings.py +108 -0
- smart_commit/exceptions.py +102 -0
- smart_commit/execution/__init__.py +6 -0
- smart_commit/execution/committer.py +102 -0
- smart_commit/git/__init__.py +8 -0
- smart_commit/git/safety.py +75 -0
- smart_commit/git/scanner.py +182 -0
- smart_commit/git/service.py +129 -0
- smart_commit/git_service.py +302 -0
- smart_commit/logger.py +99 -0
- smart_commit/models.py +73 -0
- smart_commit/plugins/__init__.py +6 -0
- smart_commit/plugins/base.py +135 -0
- smart_commit/plugins/django.py +40 -0
- smart_commit/plugins/fastapi.py +39 -0
- smart_commit/plugins/react.py +49 -0
- smart_commit/preview/__init__.py +6 -0
- smart_commit/preview/editor.py +224 -0
- smart_commit/preview/renderer.py +94 -0
- smart_commit/preview.py +131 -0
- smart_commit/summarizer.py +93 -0
- smart_commit/utils.py +83 -0
- smart_commit_cli-0.1.0.dist-info/METADATA +804 -0
- smart_commit_cli-0.1.0.dist-info/RECORD +53 -0
- smart_commit_cli-0.1.0.dist-info/WHEEL +4 -0
- smart_commit_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Commit execution and session management."""
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .models import CommitCluster, SessionInfo, FileDiff
|
|
7
|
+
from .git_service import GitService
|
|
8
|
+
from .utils import generate_session_id, get_timestamp
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Committer:
|
|
12
|
+
"""Handles file staging, commit execution, and session management."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, git_service: GitService):
|
|
15
|
+
"""Initialize committer.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
git_service: GitService instance.
|
|
19
|
+
"""
|
|
20
|
+
self.git_service = git_service
|
|
21
|
+
self.session = SessionInfo(
|
|
22
|
+
session_id=generate_session_id(),
|
|
23
|
+
timestamp=get_timestamp(),
|
|
24
|
+
branch=git_service.current_branch(),
|
|
25
|
+
repository_path=str(git_service.repo_path)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def commit_cluster(self, cluster: CommitCluster) -> str:
|
|
29
|
+
"""Stage files and create a commit for a cluster.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
cluster: CommitCluster to commit.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Commit hash.
|
|
36
|
+
"""
|
|
37
|
+
# Extract file paths
|
|
38
|
+
file_paths = [f.file_path for f in cluster.files]
|
|
39
|
+
|
|
40
|
+
# Stage files
|
|
41
|
+
self.git_service.stage_files(file_paths)
|
|
42
|
+
|
|
43
|
+
# Create commit
|
|
44
|
+
message = cluster.message or "Automatic commit"
|
|
45
|
+
commit_hash = self.git_service.commit(message)
|
|
46
|
+
|
|
47
|
+
# Track in session
|
|
48
|
+
self.session.commit_hashes.append(commit_hash)
|
|
49
|
+
|
|
50
|
+
return commit_hash
|
|
51
|
+
|
|
52
|
+
def commit_clusters(self, clusters: List[CommitCluster]) -> List[str]:
|
|
53
|
+
"""Commit multiple clusters in sequence.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
clusters: List of CommitCluster objects.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
List of commit hashes.
|
|
60
|
+
"""
|
|
61
|
+
commit_hashes = []
|
|
62
|
+
|
|
63
|
+
for cluster in clusters:
|
|
64
|
+
try:
|
|
65
|
+
commit_hash = self.commit_cluster(cluster)
|
|
66
|
+
commit_hashes.append(commit_hash)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
# Log error but continue with next cluster
|
|
69
|
+
print(f"Error committing cluster: {e}")
|
|
70
|
+
|
|
71
|
+
# Save session for undo support
|
|
72
|
+
self.git_service.save_session(self.session)
|
|
73
|
+
|
|
74
|
+
return commit_hashes
|
|
75
|
+
|
|
76
|
+
def stage_files(self, file_paths: List[str]) -> None:
|
|
77
|
+
"""Stage specific files.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
file_paths: List of file paths to stage.
|
|
81
|
+
"""
|
|
82
|
+
self.git_service.stage_files(file_paths)
|
|
83
|
+
|
|
84
|
+
def unstage_files(self, file_paths: List[str]) -> None:
|
|
85
|
+
"""Unstage specific files.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
file_paths: List of file paths to unstage.
|
|
89
|
+
"""
|
|
90
|
+
self.git_service.unstage_files(file_paths)
|
|
91
|
+
|
smart_commit/config.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Configuration management for Smart Commit."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
except ImportError: # pragma: no cover - optional dependency fallback
|
|
11
|
+
def load_dotenv() -> None:
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
load_dotenv()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Config:
|
|
20
|
+
"""Smart Commit configuration."""
|
|
21
|
+
|
|
22
|
+
provider: str = "openrouter"
|
|
23
|
+
model: str = "anthropic/claude-sonnet"
|
|
24
|
+
api_key: Optional[str] = None
|
|
25
|
+
embedding_model: str = "all-MiniLM-L6-v2"
|
|
26
|
+
max_files_per_commit: int = 8
|
|
27
|
+
clustering_method: str = "agglomerative"
|
|
28
|
+
similarity_threshold: float = 0.5
|
|
29
|
+
interactive: bool = True
|
|
30
|
+
auto_push: bool = False
|
|
31
|
+
conventional_commits: bool = True
|
|
32
|
+
use_local_embeddings: bool = True
|
|
33
|
+
max_diff_size: int = 100000
|
|
34
|
+
session_history_dir: str = ".smart_commit"
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def load_from_file(config_path: Path) -> "Config":
|
|
38
|
+
"""Load configuration from YAML."""
|
|
39
|
+
if not config_path.exists():
|
|
40
|
+
return Config.from_env()
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
import yaml
|
|
44
|
+
except ImportError:
|
|
45
|
+
return Config.from_env()
|
|
46
|
+
|
|
47
|
+
with config_path.open(encoding="utf-8") as file:
|
|
48
|
+
data = yaml.safe_load(file) or {}
|
|
49
|
+
env_config = Config.from_env()
|
|
50
|
+
merged = asdict(env_config)
|
|
51
|
+
merged.update(data)
|
|
52
|
+
return Config(**merged)
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def from_env() -> "Config":
|
|
56
|
+
"""Load configuration from environment variables."""
|
|
57
|
+
return Config(
|
|
58
|
+
provider=os.getenv("SMART_COMMIT_PROVIDER", "openrouter"),
|
|
59
|
+
model=os.getenv("SMART_COMMIT_MODEL", "anthropic/claude-sonnet"),
|
|
60
|
+
api_key=os.getenv("SMART_COMMIT_API_KEY"),
|
|
61
|
+
embedding_model=os.getenv("SMART_COMMIT_EMBEDDING_MODEL", "all-MiniLM-L6-v2"),
|
|
62
|
+
max_files_per_commit=int(os.getenv("SMART_COMMIT_MAX_FILES_PER_COMMIT", "8")),
|
|
63
|
+
clustering_method=os.getenv("SMART_COMMIT_CLUSTERING_METHOD", "agglomerative"),
|
|
64
|
+
similarity_threshold=float(os.getenv("SMART_COMMIT_SIMILARITY_THRESHOLD", "0.5")),
|
|
65
|
+
interactive=os.getenv("SMART_COMMIT_INTERACTIVE", "true").lower() != "false",
|
|
66
|
+
auto_push=os.getenv("SMART_COMMIT_AUTO_PUSH", "false").lower() == "true",
|
|
67
|
+
conventional_commits=os.getenv("SMART_COMMIT_CONVENTIONAL_COMMITS", "true").lower()
|
|
68
|
+
!= "false",
|
|
69
|
+
use_local_embeddings=os.getenv("SMART_COMMIT_USE_LOCAL_EMBEDDINGS", "true").lower()
|
|
70
|
+
!= "false",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def load() -> "Config":
|
|
75
|
+
"""Load configuration from file, then environment defaults."""
|
|
76
|
+
return Config.load_from_file(Path.home() / ".smart_commit" / "config.yaml")
|
|
77
|
+
|
|
78
|
+
def save_to_file(self, config_path: Path) -> None:
|
|
79
|
+
"""Save configuration as YAML."""
|
|
80
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
try:
|
|
82
|
+
import yaml
|
|
83
|
+
|
|
84
|
+
with config_path.open("w", encoding="utf-8") as file:
|
|
85
|
+
yaml.safe_dump(asdict(self), file, default_flow_style=False)
|
|
86
|
+
except ImportError:
|
|
87
|
+
lines = [f"{key}: {value}" for key, value in asdict(self).items()]
|
|
88
|
+
config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Constants for Smart Commit."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# Version
|
|
7
|
+
VERSION = "0.2.0"
|
|
8
|
+
|
|
9
|
+
# Default configuration
|
|
10
|
+
DEFAULT_EMBEDDING_MODEL = "all-MiniLM-L6-v2"
|
|
11
|
+
DEFAULT_LLM_PROVIDER = "openrouter"
|
|
12
|
+
DEFAULT_LLM_MODEL = "anthropic/claude-sonnet"
|
|
13
|
+
DEFAULT_MAX_FILES_PER_COMMIT = 8
|
|
14
|
+
DEFAULT_SIMILARITY_THRESHOLD = 0.5
|
|
15
|
+
DEFAULT_MAX_DIFF_SIZE = 100000 # chars
|
|
16
|
+
|
|
17
|
+
# Configuration file locations
|
|
18
|
+
CONFIG_DIR = Path.home() / ".smart_commit"
|
|
19
|
+
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
20
|
+
SESSION_DIR = CONFIG_DIR / "sessions"
|
|
21
|
+
PLUGINS_DIR = CONFIG_DIR / "plugins"
|
|
22
|
+
|
|
23
|
+
# Weight factors for similarity graph
|
|
24
|
+
SIMILARITY_WEIGHTS = {
|
|
25
|
+
"semantic": 0.45, # Embeddings
|
|
26
|
+
"directory": 0.20, # Same folder
|
|
27
|
+
"imports": 0.15, # Import overlap
|
|
28
|
+
"symbols": 0.15, # Symbol overlap
|
|
29
|
+
"filename": 0.05 # Filename similarity
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Max retries for LLM
|
|
33
|
+
MAX_LLM_RETRIES = 3
|
|
34
|
+
LLM_TIMEOUT = 30 # seconds
|
|
35
|
+
|
|
36
|
+
# File size limits
|
|
37
|
+
MAX_FILE_DIFF_SIZE = 500000 # 500KB per file
|
|
38
|
+
MAX_TOTAL_DIFF_SIZE = 5000000 # 5MB total
|
|
39
|
+
|
|
40
|
+
# Clustering
|
|
41
|
+
DEFAULT_CLUSTERING_METHOD = "agglomerative"
|
|
42
|
+
MIN_CLUSTER_SIZE = 1
|
|
43
|
+
MAX_CLUSTER_SIZE = 10
|
|
44
|
+
|
|
45
|
+
# UI
|
|
46
|
+
PROGRESS_BAR_WIDTH = 40
|
|
47
|
+
MAX_FILES_IN_PREVIEW = 5
|
|
48
|
+
PREVIEW_WRAP_LENGTH = 80
|
|
49
|
+
|
|
50
|
+
# Commit message
|
|
51
|
+
MAX_COMMIT_TITLE_LENGTH = 72
|
|
52
|
+
MAX_COMMIT_BODY_LENGTH = 100
|
|
53
|
+
|
|
54
|
+
# Session
|
|
55
|
+
SESSION_HISTORY_MAX = 50
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CommitAction(str, Enum):
|
|
59
|
+
"""User actions in preview."""
|
|
60
|
+
ACCEPT = "a"
|
|
61
|
+
EDIT = "e"
|
|
62
|
+
SPLIT = "s"
|
|
63
|
+
MERGE = "m"
|
|
64
|
+
DISCARD = "d"
|
|
65
|
+
SKIP = "k"
|
|
66
|
+
QUIT = "q"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class FileStatus(str, Enum):
|
|
70
|
+
"""Git file status."""
|
|
71
|
+
ADDED = "A"
|
|
72
|
+
MODIFIED = "M"
|
|
73
|
+
DELETED = "D"
|
|
74
|
+
RENAMED = "R"
|
|
75
|
+
COPIED = "C"
|
|
76
|
+
UNTRACKED = "U"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ClusteringMethod(str, Enum):
|
|
80
|
+
"""Clustering algorithm options."""
|
|
81
|
+
AGGLOMERATIVE = "agglomerative"
|
|
82
|
+
HDBSCAN = "hdbscan"
|
|
83
|
+
HEURISTIC = "heuristic"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ConfidenceLevel(str, Enum):
|
|
87
|
+
"""Confidence levels for clustering decisions."""
|
|
88
|
+
LOW = "low"
|
|
89
|
+
MEDIUM = "medium"
|
|
90
|
+
HIGH = "high"
|
|
91
|
+
VERY_HIGH = "very_high"
|
|
92
|
+
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Diff parser for extracting structured information from git diffs."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Optional, List, Tuple
|
|
5
|
+
from .models import FileDiff
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DiffParser:
|
|
9
|
+
"""Parses git diffs and extracts structured information."""
|
|
10
|
+
|
|
11
|
+
def __init__(self):
|
|
12
|
+
"""Initialize the diff parser."""
|
|
13
|
+
self.language_keywords = {
|
|
14
|
+
".py": ["def ", "class ", "import ", "from "],
|
|
15
|
+
".js": ["function ", "class ", "import ", "export "],
|
|
16
|
+
".ts": ["interface ", "type ", "class ", "function ", "import "],
|
|
17
|
+
".tsx": ["interface ", "function ", "export ", "const ", "import "],
|
|
18
|
+
".jsx": ["function ", "export ", "import ", "const "],
|
|
19
|
+
".java": ["class ", "public ", "private ", "interface "],
|
|
20
|
+
".cpp": ["class ", "void ", "int ", "#include "],
|
|
21
|
+
".go": ["func ", "type ", "package ", "import "],
|
|
22
|
+
".rs": ["fn ", "struct ", "impl ", "use "],
|
|
23
|
+
".rb": ["def ", "class ", "module ", "require "],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def parse_diff(self, raw_diff: str, file_path: str) -> List[str]:
|
|
27
|
+
"""Extract meaningful changes from a raw diff.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
raw_diff: Raw diff content from git.
|
|
31
|
+
file_path: Path to the file.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
List of change descriptions.
|
|
35
|
+
"""
|
|
36
|
+
if not raw_diff.strip():
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
changes = []
|
|
40
|
+
|
|
41
|
+
# Detect file extension
|
|
42
|
+
file_ext = self._get_file_extension(file_path)
|
|
43
|
+
|
|
44
|
+
# Parse diff lines
|
|
45
|
+
lines = raw_diff.split('\n')
|
|
46
|
+
added_lines = [l[1:] for l in lines if l.startswith('+') and not l.startswith('+++')]
|
|
47
|
+
removed_lines = [l[1:] for l in lines if l.startswith('-') and not l.startswith('---')]
|
|
48
|
+
|
|
49
|
+
# Extract high-level changes
|
|
50
|
+
if added_lines and not removed_lines:
|
|
51
|
+
changes.append("Added new content")
|
|
52
|
+
elif removed_lines and not added_lines:
|
|
53
|
+
changes.append("Removed content")
|
|
54
|
+
elif added_lines and removed_lines:
|
|
55
|
+
changes.append("Modified content")
|
|
56
|
+
|
|
57
|
+
# Detect function/class changes
|
|
58
|
+
keywords = self.language_keywords.get(file_ext, [])
|
|
59
|
+
|
|
60
|
+
for line in added_lines:
|
|
61
|
+
for keyword in keywords:
|
|
62
|
+
if keyword in line:
|
|
63
|
+
# Extract the identifier
|
|
64
|
+
match = re.search(rf"{keyword}(\w+)", line)
|
|
65
|
+
if match:
|
|
66
|
+
identifier = match.group(1)
|
|
67
|
+
changes.append(f"Added {keyword.strip().rstrip('(')} '{identifier}'")
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
for line in removed_lines:
|
|
71
|
+
for keyword in keywords:
|
|
72
|
+
if keyword in line:
|
|
73
|
+
match = re.search(rf"{keyword}(\w+)", line)
|
|
74
|
+
if match:
|
|
75
|
+
identifier = match.group(1)
|
|
76
|
+
changes.append(f"Removed {keyword.strip().rstrip('(')} '{identifier}'")
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
# Count stats
|
|
80
|
+
num_added = len([l for l in lines if l.startswith('+')])
|
|
81
|
+
num_removed = len([l for l in lines if l.startswith('-')])
|
|
82
|
+
|
|
83
|
+
if num_added > 0 or num_removed > 0:
|
|
84
|
+
changes.append(f"{num_added} lines added, {num_removed} lines removed")
|
|
85
|
+
|
|
86
|
+
return changes
|
|
87
|
+
|
|
88
|
+
def extract_summary(self, raw_diff: str, file_path: str) -> str:
|
|
89
|
+
"""Extract a one-line summary of changes.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
raw_diff: Raw diff content from git.
|
|
93
|
+
file_path: Path to the file.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
One-line summary of changes.
|
|
97
|
+
"""
|
|
98
|
+
changes = self.parse_diff(raw_diff, file_path)
|
|
99
|
+
|
|
100
|
+
if not changes:
|
|
101
|
+
return "Modified file"
|
|
102
|
+
|
|
103
|
+
# Take the most significant changes
|
|
104
|
+
primary_changes = [c for c in changes if 'Added' in c or 'Removed' in c]
|
|
105
|
+
|
|
106
|
+
if primary_changes:
|
|
107
|
+
return primary_changes[0]
|
|
108
|
+
|
|
109
|
+
return changes[0]
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _get_file_extension(file_path: str) -> str:
|
|
113
|
+
"""Get file extension."""
|
|
114
|
+
if '.' not in file_path:
|
|
115
|
+
return ""
|
|
116
|
+
|
|
117
|
+
ext = '.' + file_path.split('.')[-1]
|
|
118
|
+
return ext
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def get_file_type(file_path: str) -> str:
|
|
122
|
+
"""Determine file type category.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
file_path: Path to file.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
File type category.
|
|
129
|
+
"""
|
|
130
|
+
file_lower = file_path.lower()
|
|
131
|
+
|
|
132
|
+
# Frontend files
|
|
133
|
+
if any(x in file_lower for x in ['.jsx', '.tsx', '.vue', '.css', '.scss']):
|
|
134
|
+
return "frontend"
|
|
135
|
+
|
|
136
|
+
# Backend files
|
|
137
|
+
if any(x in file_lower for x in ['.py', '.java', '.go', '.rs', '.rb', '.php']):
|
|
138
|
+
return "backend"
|
|
139
|
+
|
|
140
|
+
# Config/build files
|
|
141
|
+
if any(x in file_lower for x in ['package.json', 'pyproject.toml', 'requirements.txt',
|
|
142
|
+
'dockerfile', '.env', 'config.yaml']):
|
|
143
|
+
return "config"
|
|
144
|
+
|
|
145
|
+
# Test files
|
|
146
|
+
if 'test' in file_lower or 'spec' in file_lower:
|
|
147
|
+
return "test"
|
|
148
|
+
|
|
149
|
+
# Documentation
|
|
150
|
+
if any(x in file_lower for x in ['.md', '.rst', '.txt']):
|
|
151
|
+
return "docs"
|
|
152
|
+
|
|
153
|
+
# Database/data files
|
|
154
|
+
if any(x in file_lower for x in ['.sql', '.json', '.xml', '.csv']):
|
|
155
|
+
return "data"
|
|
156
|
+
|
|
157
|
+
return "other"
|
|
158
|
+
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Semantic embeddings generation."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from ..models import FileDiff, CommitCluster
|
|
7
|
+
from ..logger import get_logger
|
|
8
|
+
from ..constants import DEFAULT_EMBEDDING_MODEL
|
|
9
|
+
from ..cache import cache_manager
|
|
10
|
+
|
|
11
|
+
logger = get_logger("embeddings.generator")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EmbeddingsGenerator:
|
|
15
|
+
"""Generates semantic embeddings using sentence transformers."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, model_name: str = DEFAULT_EMBEDDING_MODEL):
|
|
18
|
+
"""Initialize embeddings generator.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
model_name: Sentence transformer model name.
|
|
22
|
+
"""
|
|
23
|
+
self.model_name = model_name
|
|
24
|
+
self.model = None
|
|
25
|
+
self._init_model()
|
|
26
|
+
|
|
27
|
+
def _init_model(self):
|
|
28
|
+
"""Initialize the embedding model."""
|
|
29
|
+
try:
|
|
30
|
+
from sentence_transformers import SentenceTransformer
|
|
31
|
+
logger.info(f"Loading embedding model: {self.model_name}")
|
|
32
|
+
self.model = SentenceTransformer(self.model_name)
|
|
33
|
+
logger.debug(f"Model loaded successfully")
|
|
34
|
+
except Exception as e:
|
|
35
|
+
logger.error(f"Failed to load embedding model: {e}")
|
|
36
|
+
raise
|
|
37
|
+
|
|
38
|
+
def generate_embedding(self, text: str) -> Optional[List[float]]:
|
|
39
|
+
"""Generate embedding for text.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
text: Text to embed.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Embedding vector or None.
|
|
46
|
+
"""
|
|
47
|
+
if not text or not self.model:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
cached = cache_manager.get("embeddings", text)
|
|
51
|
+
if cached is not None:
|
|
52
|
+
return cached
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
embeddings = self.model.encode(text, convert_to_numpy=True)
|
|
56
|
+
result = embeddings.tolist()
|
|
57
|
+
cache_manager.set("embeddings", text, result)
|
|
58
|
+
return result
|
|
59
|
+
except Exception as e:
|
|
60
|
+
logger.error(f"Failed to generate embedding: {e}")
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
def generate_file_embedding(self, file_diff: FileDiff) -> Optional[List[float]]:
|
|
64
|
+
"""Generate embedding for a file diff.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
file_diff: FileDiff object.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Embedding vector or None.
|
|
71
|
+
"""
|
|
72
|
+
# Combine multiple text sources for richer embedding
|
|
73
|
+
text_parts = [
|
|
74
|
+
file_diff.file_path,
|
|
75
|
+
file_diff.status,
|
|
76
|
+
file_diff.summary or "",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
text = " ".join(text_parts).strip()
|
|
80
|
+
|
|
81
|
+
if not text:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
return self.generate_embedding(text)
|
|
85
|
+
|
|
86
|
+
def compute_similarity(self, embedding1: List[float],
|
|
87
|
+
embedding2: List[float]) -> float:
|
|
88
|
+
"""Compute cosine similarity between two embeddings.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
embedding1: First embedding.
|
|
92
|
+
embedding2: Second embedding.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Similarity score (0-1).
|
|
96
|
+
"""
|
|
97
|
+
if not embedding1 or not embedding2:
|
|
98
|
+
return 0.0
|
|
99
|
+
|
|
100
|
+
arr1 = np.array(embedding1)
|
|
101
|
+
arr2 = np.array(embedding2)
|
|
102
|
+
|
|
103
|
+
# Cosine similarity
|
|
104
|
+
dot = np.dot(arr1, arr2)
|
|
105
|
+
norm1 = np.linalg.norm(arr1)
|
|
106
|
+
norm2 = np.linalg.norm(arr2)
|
|
107
|
+
|
|
108
|
+
if norm1 == 0 or norm2 == 0:
|
|
109
|
+
return 0.0
|
|
110
|
+
|
|
111
|
+
return float(dot / (norm1 * norm2))
|
|
112
|
+
|
|
113
|
+
def compute_cluster_similarity(self, cluster1: CommitCluster,
|
|
114
|
+
cluster2: CommitCluster) -> float:
|
|
115
|
+
"""Compute similarity between two clusters.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
cluster1: First cluster.
|
|
119
|
+
cluster2: Second cluster.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Similarity score (0-1).
|
|
123
|
+
"""
|
|
124
|
+
if not cluster1.embedding or not cluster2.embedding:
|
|
125
|
+
return 0.0
|
|
126
|
+
|
|
127
|
+
return self.compute_similarity(cluster1.embedding, cluster2.embedding)
|
|
128
|
+
|