Lumiin0us commited on
Commit
34b79cd
·
0 Parent(s):

GitSpec Initial Deployment

Browse files
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ __pycache__/
3
+ *.pyc
4
+ .DS_Store
5
+ /tmp/
6
+ venv/
7
+ .venv/
8
+ .jsonl
9
+ _repo/
Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
4
+
5
+ WORKDIR /app
6
+
7
+ COPY requirements.txt .
8
+ RUN pip install --no-cache-dir -r requirements.txt
9
+
10
+ COPY . .
11
+
12
+ EXPOSE 8501
13
+
14
+ CMD ["streamlit", "run", "streamlitUI.py", "--server.port=8501", "--server.address=0.0.0.0"]
README.md ADDED
@@ -0,0 +1 @@
 
 
1
+ GitSpec - AI Powered Git Inspection Tool
backend/__init__.py ADDED
File without changes
backend/clone.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import shutil
3
+ import stat
4
+ import tempfile
5
+ from git import Repo
6
+
7
+ def readOnlyHandler(func, path, execinfo):
8
+ os.chmod(path, stat.S_IWRITE)
9
+ func(path)
10
+
11
+ def cloneRepo(repository):
12
+ basePath = tempfile.gettempdir()
13
+ folderName = repository.rstrip('/').split('/')[-1]
14
+ destinationPath = os.path.join(basePath, folderName)
15
+
16
+ if os.path.exists(destinationPath):
17
+ shutil.rmtree(destinationPath, onerror=readOnlyHandler)
18
+
19
+ try:
20
+ repo = Repo.clone_from(repository, destinationPath)
21
+ return destinationPath, repo
22
+ except Exception as e:
23
+ print("Error: ", e)
24
+ return None, None
25
+
26
+ def cleanupRepo(path):
27
+ try:
28
+ if os.path.exists(path):
29
+ shutil.rmtree(path, onerror=readOnlyHandler)
30
+ except Exception as e:
31
+ print(f"Cleanup warning: {e}")
backend/crawl.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ def repoCrawler(folderName):
4
+ scriptDir = os.path.dirname(os.path.abspath(__file__))
5
+ pythonFiles = []
6
+
7
+ for (root,dirs,files) in os.walk(os.path.join(scriptDir, folderName),topdown=True):
8
+ for file in files:
9
+ if file.lower().endswith('.py'):
10
+ pythonFiles.append(os.path.join(root, file))
11
+ return pythonFiles
backend/extract.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import libcst as cst
2
+ import shortuuid
3
+ import json
4
+ import textwrap
5
+ import os
6
+
7
+ class CallVisitor(cst.CSTVisitor):
8
+ def __init__(self):
9
+ super().__init__()
10
+ self.calls = set()
11
+
12
+ def visit_Call(self, node: cst.Call):
13
+ if isinstance(node.func, cst.Name):
14
+ self.calls.add(node.func.value)
15
+ elif isinstance(node.func, cst.Attribute):
16
+ if isinstance(node.func.attr, cst.Name):
17
+ self.calls.add(node.func.attr.value)
18
+
19
+ class MethodRemover(cst.CSTTransformer):
20
+ """Removes top-level methods from a class body to create the 'Shell'."""
21
+ def leave_FunctionDef(self, originalNode, updatedNode):
22
+ return cst.RemoveFromParent()
23
+
24
+ class GlobalRemover(cst.CSTTransformer):
25
+ """Isolates global variables by stripping functions, classes, and imports."""
26
+ def leave_FunctionDef(self, originalNode, updatedNode): return cst.RemoveFromParent()
27
+ def leave_ClassDef(self, originalNode, updatedNode): return cst.RemoveFromParent()
28
+ def leave_Import(self, originalNode, updatedNode): return cst.RemoveFromParent()
29
+ def leave_ImportFrom(self, originalNode, updatedNode): return cst.RemoveFromParent()
30
+ def leave_If(self, originalNode, updatedNode):
31
+ if isinstance(originalNode.test, cst.Comparison):
32
+ left = originalNode.test.left
33
+ if isinstance(left, cst.Name) and left.value == "__name__":
34
+ return cst.RemoveFromParent()
35
+ return updatedNode
36
+
37
+ # Utility Functions
38
+ def get_calls(node):
39
+ visitor = CallVisitor()
40
+ node.visit(visitor)
41
+ return sorted(list(visitor.calls))
42
+
43
+ def get_module_names(module_node):
44
+ names = set()
45
+ for node in module_node.body:
46
+ if isinstance(node, cst.SimpleStatementLine):
47
+ for s in node.body:
48
+ if isinstance(s, (cst.Import, cst.ImportFrom)):
49
+ if isinstance(s, cst.Import):
50
+ for alias in s.names:
51
+ fullName = module_node.code_for_node(alias.name)
52
+ names.add(fullName.split('.')[0])
53
+ elif isinstance(s, cst.ImportFrom):
54
+ if s.module:
55
+ fullName = module_node.code_for_node(s.module)
56
+ names.add(fullName.split('.')[0])
57
+ return sorted(list(names))
58
+
59
+ # Main Processor
60
+ def processPythonFile(files, folderName, repo, outputFile="code.jsonl"):
61
+ results = []
62
+
63
+ for file in files:
64
+ relPath = os.path.relpath(file, repo.working_tree_dir)
65
+
66
+ # Git Metadata
67
+ commits = list(repo.iter_commits(paths=relPath))
68
+ if commits:
69
+ lastCommit = commits[0]
70
+ firstCommit = commits[-1]
71
+ historyMetadata = {
72
+ "lastCommit": {
73
+ "hash": lastCommit.hexsha,
74
+ "msg": lastCommit.message.strip(),
75
+ "author": lastCommit.author.name,
76
+ "date": str(lastCommit.authored_datetime)
77
+ },
78
+ "firstCommit": {
79
+ "hash": firstCommit.hexsha,
80
+ "msg": firstCommit.message.strip(),
81
+ "author": firstCommit.author.name,
82
+ "date": str(firstCommit.authored_datetime)
83
+ }
84
+ }
85
+ else:
86
+ historyMetadata = {"lastCommit": None, "firstCommit": None}
87
+
88
+ try:
89
+ with open(file, 'r', encoding='utf-8') as f:
90
+ sourceCode = f.read()
91
+ module = cst.parse_module(sourceCode)
92
+ except Exception as e:
93
+ print(f"Could not read/parse file {file}: {e}")
94
+ continue
95
+
96
+ external_deps = get_module_names(module)
97
+ globalVarsCode = module.visit(GlobalRemover()).code.strip()
98
+ filepath = folderName + file.split(folderName)[-1]
99
+
100
+ importLines = []
101
+ for node in module.body:
102
+ if isinstance(node, cst.SimpleStatementLine):
103
+ if any(isinstance(s, (cst.Import, cst.ImportFrom)) for s in node.body):
104
+ importLines.append(module.code_for_node(node).strip())
105
+ importedModulesCode = "\n".join(importLines)
106
+
107
+ for node in module.body:
108
+ # CASE 1: Top-Level Functions
109
+ if isinstance(node, cst.FunctionDef):
110
+ calls = get_calls(node)
111
+ header = (
112
+ f"# FILE: {filepath}\n"
113
+ f"# TYPE: Global Function\n"
114
+ f"# CALLS: {', '.join(calls)}\n"
115
+ f"# DEPS: {', '.join(external_deps)}\n"
116
+ f"# LAST COMMIT: {historyMetadata['lastCommit']['msg'] if historyMetadata['lastCommit'] else 'N/A'}"
117
+ )
118
+
119
+ results.append({
120
+ 'id': shortuuid.uuid(),
121
+ 'name': node.name.value,
122
+ 'filePath': filepath,
123
+ 'calls': calls,
124
+ 'external_deps': external_deps,
125
+ 'history': historyMetadata,
126
+ 'modules': importedModulesCode,
127
+ 'globalVariables': globalVarsCode,
128
+ 'parentClass': 'no parent',
129
+ 'isAsync': node.asynchronous is not None,
130
+ 'content': header + "\n\n" + module.code_for_node(node).strip(),
131
+ })
132
+
133
+ # CASE 2: Classes
134
+ elif isinstance(node, cst.ClassDef):
135
+ classShell = node.visit(MethodRemover())
136
+ classShellCode = module.code_for_node(classShell).strip()
137
+
138
+ for item in node.body.body:
139
+ if isinstance(item, cst.FunctionDef):
140
+ calls = get_calls(item)
141
+ header = (
142
+ f"# FILE: {filepath}\n"
143
+ f"# CLASS: {node.name.value}\n"
144
+ f"# TYPE: Method\n"
145
+ f"# CALLS: {', '.join(calls)}\n"
146
+ f"# DEPS: {', '.join(external_deps)}\n"
147
+ f"# LAST COMMIT: {historyMetadata['lastCommit']['msg'] if historyMetadata['lastCommit'] else 'N/A'}"
148
+ )
149
+
150
+ results.append({
151
+ 'id': shortuuid.uuid(),
152
+ 'name': item.name.value,
153
+ 'filePath': filepath,
154
+ 'calls': calls,
155
+ 'external_deps': external_deps,
156
+ 'history': historyMetadata,
157
+ 'modules': importedModulesCode,
158
+ 'globalVariables': globalVarsCode,
159
+ 'parentClass': classShellCode,
160
+ 'isAsync': item.asynchronous is not None,
161
+ 'content': header + "\n\n" + textwrap.dedent(module.code_for_node(item)).strip(),
162
+ })
163
+
164
+ return results
backend/historyExtractor.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from git import Repo, GitCommandError, NULL_TREE
3
+ import os
4
+
5
+ BOT_AUTHORS = ['github-actions[bot]', 'dependabot[bot]', 'pre-commit-ci[bot]', 'github-actions']
6
+ CODE_EXTENSIONS = ['.py']
7
+ MAX_FILES_PER_COMMIT = 20
8
+ MAX_COMMITS_TO_SCAN = 200
9
+
10
+ def splitDiffs(raw_diff):
11
+ if not raw_diff: return [], []
12
+ lines = raw_diff.split('\n')
13
+ added, removed = [], []
14
+ for line in lines:
15
+ if line.startswith('+++') or line.startswith('---') or line.startswith('\\'):
16
+ continue
17
+ if line.startswith('+'): added.append(line[1:])
18
+ elif line.startswith('-'): removed.append(line[1:])
19
+ return added, removed
20
+
21
+ def isCodeCommit(files_info):
22
+ return any(f['file'].endswith(ext) for ext in CODE_EXTENSIONS for f in files_info)
23
+
24
+ def extractHistory(repo, destPath, outputFile="commits_history.jsonl"):
25
+ try:
26
+ commits = list(repo.iter_commits(max_count=MAX_COMMITS_TO_SCAN))
27
+ except (GitCommandError, ValueError):
28
+ commits = []
29
+
30
+ indexedCount = 0
31
+ with open(outputFile, 'w', encoding='utf-8') as f:
32
+ for commit in commits:
33
+ if commit.author.name in BOT_AUTHORS:
34
+ continue
35
+
36
+ parent = None
37
+ if commit.parents:
38
+ try:
39
+ p = commit.parents[0]
40
+ repo.git.cat_file('-e', p.hexsha)
41
+ parent = p
42
+ except GitCommandError:
43
+ parent = None
44
+
45
+ if parent:
46
+ diffs = parent.diff(commit, create_patch=True)
47
+ else:
48
+ diffs = commit.diff(NULL_TREE, create_patch=True, reverse=True)
49
+
50
+ files_info = []
51
+ for d in diffs:
52
+ filePath = d.b_path if d.b_path else d.a_path
53
+ rawPatch = d.diff.decode('utf-8', errors='replace') if d.diff else ""
54
+ addedLines, removedLines = splitDiffs(rawPatch)
55
+
56
+ status = d.change_type
57
+ if not status:
58
+ if addedLines and not removedLines: status = 'A'
59
+ elif removedLines and not addedLines: status = 'D'
60
+ else: status = 'M'
61
+
62
+ files_info.append({
63
+ 'file': filePath,
64
+ 'status': status,
65
+ 'additions': addedLines[:20],
66
+ 'removals': removedLines[:20]
67
+ })
68
+
69
+ if len(files_info) > MAX_FILES_PER_COMMIT:
70
+ continue
71
+ if not isCodeCommit(files_info):
72
+ continue
73
+
74
+ filesTouched = ", ".join(fi['file'] for fi in files_info)
75
+ embedString = (f"{commit.summary} — files: {filesTouched} — "
76
+ f"author: {commit.author.name} — "
77
+ f"date: {commit.authored_datetime.date()}")
78
+
79
+ commit_info = {
80
+ 'sha': commit.hexsha[:7],
81
+ 'author': commit.author.name,
82
+ 'summary': commit.summary,
83
+ 'date': commit.authored_datetime.isoformat(),
84
+ 'changes': files_info,
85
+ 'embedText': embedString
86
+ }
87
+
88
+ f.write(json.dumps(commit_info) + '\n')
89
+ indexedCount += 1
90
+
91
+ print(f"Extracted {indexedCount} commits into {outputFile}")
92
+ return outputFile
backend/historyIndexer.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from qdrant_client import QdrantClient
3
+ from qdrant_client.models import Distance, VectorParams, PointStruct
4
+ from sentence_transformers import SentenceTransformer
5
+
6
+ def indexHistory(commitsFile, client, model):
7
+ """
8
+ Reads a JSONL file of filtered commits and indexes them into Qdrant.
9
+ """
10
+ collectionName = "historyIndex"
11
+
12
+ if client.collection_exists(collectionName):
13
+ client.delete_collection(collectionName)
14
+
15
+ client.create_collection(
16
+ collection_name=collectionName,
17
+ vectors_config=VectorParams(size=384, distance=Distance.COSINE),
18
+ )
19
+
20
+ points = []
21
+
22
+ with open(commitsFile, 'r', encoding='utf-8') as f:
23
+ for i, line in enumerate(f):
24
+ commit = json.loads(line)
25
+
26
+ vector = model.encode(commit['embedText']).tolist()
27
+
28
+ points.append(
29
+ PointStruct(
30
+ id=i,
31
+ vector=vector,
32
+ payload=commit
33
+ )
34
+ )
35
+
36
+ if points:
37
+ client.upsert(collection_name=collectionName, points=points)
38
+ print(f"Successfully indexed {len(points)} commits into {collectionName}")
39
+ else:
40
+ print("No commits found in file to index.")
41
+
42
+ return client, model
backend/indexer.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from qdrant_client import QdrantClient
2
+ from qdrant_client.models import Distance, VectorParams, PointStruct
3
+ from sentence_transformers import SentenceTransformer
4
+
5
+ def indexer(results):
6
+ # Qdrant IN RAM
7
+ client = QdrantClient(":memory:")
8
+
9
+ # Code-Strong model
10
+ model = SentenceTransformer('all-MiniLM-L6-v2')
11
+
12
+ if client.collection_exists("tempCollection"):
13
+ client.delete_collection("tempCollection")
14
+
15
+ client.create_collection(
16
+ collection_name="tempCollection",
17
+ vectors_config=VectorParams(size=384, distance=Distance.COSINE),
18
+ )
19
+ points = []
20
+ for i, entry in enumerate(results):
21
+ vector = model.encode(entry['content']).tolist()
22
+ points.append(
23
+ PointStruct(
24
+ id=i,
25
+ vector=vector,
26
+ payload=entry
27
+ )
28
+ )
29
+ client.upsert(collection_name="tempCollection", points=points)
30
+ return client, model
backend/router.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from groq import Groq
2
+ import os
3
+ import json
4
+ from dotenv import load_dotenv
5
+
6
+ load_dotenv()
7
+
8
+ groqClient = Groq(api_key=os.getenv("GROQ_API_KEY"))
9
+
10
+ def routeQuery(query: str) -> str:
11
+ """
12
+ Classifies a user query into one of three routes:
13
+ CODE - search the code index (current structure, how things work)
14
+ HISTORY - search the history index (why things changed, who changed what)
15
+ BOTH - search both indexes and merge results
16
+ """
17
+
18
+ prompt = f"""You are a query router for a code intelligence tool called GitSpec.
19
+ GitSpec has two knowledge bases:
20
+
21
+ 1. CODE INDEX — contains the current source code: functions, classes, methods, imports.
22
+ Use this for questions about how something works RIGHT NOW.
23
+ Examples: "how does authentication work?", "what does process_order do?", "what classes exist in the payment module?"
24
+
25
+ 2. HISTORY INDEX — contains git commit history: what changed, when, who changed it, and why.
26
+ Use this for questions about change over time, blame, or reasoning.
27
+ Examples: "why did the auth module change?", "who introduced rate limiting?", "what changed last month?"
28
+
29
+ 3. BOTH — use when the question needs current code understanding AND historical context.
30
+ Examples: "how does auth work and why was it redesigned?", "what does checkout do and when was it last changed?"
31
+
32
+ Classify this query into exactly one of: CODE, HISTORY, BOTH
33
+
34
+ Query: {query}
35
+
36
+ Respond with valid JSON only. No explanation. No markdown.
37
+ Format: {{"route": "CODE"}} or {{"route": "HISTORY"}} or {{"route": "BOTH"}}"""
38
+
39
+ response = groqClient.chat.completions.create(
40
+ model="llama-3.3-70b-versatile",
41
+ messages=[{"role": "user", "content": prompt}],
42
+ temperature=0.0,
43
+ max_tokens=20,
44
+ )
45
+
46
+ raw = response.choices[0].message.content.strip()
47
+
48
+ try:
49
+ result = json.loads(raw)
50
+ route = result.get("route", "BOTH").upper()
51
+ if route not in ["CODE", "HISTORY", "BOTH"]:
52
+ route = "BOTH"
53
+ return route
54
+ except json.JSONDecodeError:
55
+ return "BOTH"
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ streamlit
2
+ qdrant-client
3
+ sentence-transformers
4
+ libcst
5
+ shortuuid
6
+ GitPython
streamlitUI.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ from backend.clone import cloneRepo, cleanupRepo
3
+ from backend.crawl import repoCrawler
4
+ from backend.extract import processPythonFile
5
+ from backend.indexer import indexer
6
+ from backend.historyExtractor import extractHistory
7
+ from backend.historyIndexer import indexHistory
8
+ from backend.router import routeQuery
9
+ from groq import Groq
10
+ from dotenv import load_dotenv
11
+ import os
12
+
13
+ load_dotenv()
14
+ groqApiKey = os.getenv("GROQ_API_KEY")
15
+
16
+ st.set_page_config(page_title="GitSpec", layout="wide")
17
+ st.title("GitSpec")
18
+
19
+ if "client" not in st.session_state:
20
+ st.session_state.client = None
21
+ st.session_state.model = None
22
+ st.session_state.repo_name = ""
23
+
24
+ if "messages" not in st.session_state:
25
+ st.session_state.messages = []
26
+
27
+ # Sidebar Controls
28
+ with st.sidebar:
29
+ st.header("Settings")
30
+ if st.button("Clear Chat History"):
31
+ st.session_state.messages = []
32
+ st.rerun()
33
+ st.divider()
34
+ contextLimit = st.slider("Context Depth (Snippets)", 3, 10, 6)
35
+
36
+ repoUrl = st.text_input("Enter GitHub Repository URL:")
37
+
38
+ if st.button("Analyze Repository"):
39
+ if repoUrl:
40
+ st.session_state.repo_name = repoUrl.split('/')[-1].replace('.git', '')
41
+ progressBar = st.progress(0)
42
+ statusText = st.empty()
43
+
44
+ statusText.text("Cloning repository...")
45
+ progressBar.progress(10)
46
+ destPath, repo = cloneRepo(repoUrl)
47
+
48
+ if destPath and repo:
49
+ progressBar.progress(25)
50
+ statusText.text("Scanning files...")
51
+ files = repoCrawler(destPath)
52
+
53
+ progressBar.progress(40)
54
+ statusText.text("Extracting code structures...")
55
+ results = processPythonFile(files, destPath, repo)
56
+
57
+ progressBar.progress(55)
58
+ statusText.text("Indexing code...")
59
+ client, model = indexer(results)
60
+
61
+ progressBar.progress(70)
62
+ statusText.text("Extracting commit history...")
63
+ commitsFile = extractHistory(repo, destPath)
64
+
65
+ progressBar.progress(85)
66
+ statusText.text("Indexing history...")
67
+ client, model = indexHistory(commitsFile, client, model)
68
+
69
+ st.session_state.client = client
70
+ st.session_state.model = model
71
+ st.session_state.messages = []
72
+
73
+ progressBar.progress(100)
74
+ statusText.text("Cleaning up...")
75
+ cleanupRepo(destPath)
76
+
77
+ statusText.empty()
78
+ progressBar.empty()
79
+ st.success(f"'{st.session_state.repo_name}' Repository indexed successfully")
80
+ else:
81
+ statusText.empty()
82
+ progressBar.empty()
83
+ st.error("Failed to clone repository.")
84
+ else:
85
+ st.warning("Please enter a URL.")
86
+
87
+ if st.session_state.client:
88
+ st.divider()
89
+
90
+ for message in st.session_state.messages:
91
+ with st.chat_message(message["role"]):
92
+ st.markdown(message["content"])
93
+
94
+ if query := st.chat_input("Ask anything about the codebase or its history..."):
95
+
96
+ with st.chat_message("user"):
97
+ st.markdown(query)
98
+ st.session_state.messages.append({"role": "user", "content": query})
99
+
100
+ groqClient = Groq(api_key=groqApiKey)
101
+
102
+ with st.spinner("Thinking..."):
103
+
104
+ # Route the query
105
+ route = routeQuery(query)
106
+
107
+ queryVector = st.session_state.model.encode(query).tolist()
108
+ contextBlocks = []
109
+ searchResults = []
110
+
111
+ # Search the right index(es)
112
+ if route in ["CODE", "BOTH"]:
113
+ codeResponse = st.session_state.client.query_points(
114
+ collection_name="tempCollection",
115
+ query=queryVector,
116
+ limit=contextLimit
117
+ )
118
+ for res in codeResponse.points:
119
+ p = res.payload
120
+ block = (
121
+ f"[CODE] FILE: {p.get('filePath')}\n"
122
+ f"METADATA: Parent={p.get('parentClass')}, Imports={p.get('modules')}\n"
123
+ f"LAST COMMIT: {p.get('history', {}).get('lastCommit', {}).get('msg')}\n"
124
+ f"CODE:\n{p.get('content')}"
125
+ )
126
+ contextBlocks.append(block)
127
+ searchResults.append(("code", res))
128
+
129
+ if route in ["HISTORY", "BOTH"]:
130
+ historyResponse = st.session_state.client.query_points(
131
+ collection_name="historyIndex",
132
+ query=queryVector,
133
+ limit=contextLimit
134
+ )
135
+ for res in historyResponse.points:
136
+ p = res.payload
137
+ block = (
138
+ f"[HISTORY] COMMIT: {p.get('sha')} by {p.get('author')} on {p.get('date', '')[:10]}\n"
139
+ f"SUMMARY: {p.get('summary')}\n"
140
+ f"FILES: {', '.join(f['file'] for f in p.get('changes', []))}\n"
141
+ f"CHANGES: {p.get('embedText')}"
142
+ )
143
+ contextBlocks.append(block)
144
+ searchResults.append(("history", res))
145
+
146
+ if not contextBlocks:
147
+ fullResponse = "I couldn't find relevant information. Try rephrasing your question."
148
+ else:
149
+ formattedContext = "\n---\n".join(contextBlocks)
150
+
151
+ systemPrompts = (
152
+ "You are a Senior Software Architect and code historian for GitSpec. "
153
+ "You have access to two knowledge sources: current source code (CODE) and git commit history (HISTORY). "
154
+ "Use whichever is relevant to answer the question accurately."
155
+ "\n\nSTRICT FORMATTING RULES:"
156
+ "\n- Start immediately with the answer."
157
+ "\n- Use H3 headers (###) for distinct sections."
158
+ "\n- For history answers, always cite the commit SHA and author."
159
+ "\n- For code answers, reference the file and function name."
160
+ "\n- Do NOT include absolute local file paths."
161
+ "\n- Keep the tone professional and concise."
162
+ "\n- Be concise. No 'In conclusion' or summary sections — end when the answer is complete."
163
+
164
+ )
165
+
166
+ llm_messages = [
167
+ {"role": "system", "content": systemPrompts},
168
+ *st.session_state.messages[-5:],
169
+ {"role": "user", "content": f"Context:\n{formattedContext}\n\nQuestion: {query}"}
170
+ ]
171
+ try:
172
+ llmResponse = groqClient.chat.completions.create(
173
+ model="llama-3.3-70b-versatile",
174
+ messages=llm_messages,
175
+ temperature=0.1
176
+ )
177
+ fullResponse = llmResponse.choices[0].message.content
178
+ except Exception as e:
179
+ if "rate_limit_exceeded" in str(e).lower() or "413" in str(e):
180
+ fullResponse = (
181
+ "**Rate Limit Reached:** The context for this repository is quite large for the free tier. "
182
+ "I've tried to answer, but Groq is busy. Please try: \n"
183
+ "1. Reducing the **Context Depth** slider in the sidebar.\n"
184
+ "2. Asking a more specific question.\n"
185
+ "3. Waiting 60 seconds and trying again."
186
+ )
187
+ else:
188
+ fullResponse = f"An unexpected error occurred: {str(e)}"
189
+
190
+
191
+ # Display answer
192
+ with st.chat_message("assistant"):
193
+ st.markdown(fullResponse)
194
+ st.caption(f"Route: `{route}`")
195
+
196
+ if searchResults:
197
+ with st.expander("Explore Reference Sources"):
198
+ for sourceType, res in searchResults:
199
+ p = res.payload
200
+ if sourceType == "code":
201
+ st.caption(f"[CODE] {p.get('filePath')}")
202
+ st.code(p.get('content'), language='python')
203
+ else:
204
+ st.caption(f"[HISTORY] {p.get('sha')} — {p.get('summary')}")
205
+ for change in p.get('changes', []):
206
+ st.caption(f"File: {change['file']} ({change['status']})")
207
+
208
+ st.session_state.messages.append({"role": "assistant", "content": fullResponse})