cli-memory-os 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.
- cli/__init__.py +1 -0
- cli/commands/__init__.py +1 -0
- cli/commands/benchmark.py +151 -0
- cli/commands/config_cmd.py +57 -0
- cli/commands/doctor.py +61 -0
- cli/commands/export.py +106 -0
- cli/commands/import_cmd.py +143 -0
- cli/commands/init.py +205 -0
- cli/commands/logs.py +38 -0
- cli/commands/monitor.py +63 -0
- cli/commands/plugins.py +37 -0
- cli/commands/start.py +24 -0
- cli/commands/status.py +92 -0
- cli/commands/stop.py +19 -0
- cli/commands/version.py +25 -0
- cli/commands/workspace.py +154 -0
- cli/main.py +496 -0
- cli/parser.py +188 -0
- cli_memory_os-0.1.0.dist-info/METADATA +231 -0
- cli_memory_os-0.1.0.dist-info/RECORD +50 -0
- cli_memory_os-0.1.0.dist-info/WHEEL +5 -0
- cli_memory_os-0.1.0.dist-info/entry_points.txt +2 -0
- cli_memory_os-0.1.0.dist-info/licenses/LICENSE +21 -0
- cli_memory_os-0.1.0.dist-info/top_level.txt +6 -0
- connectors/__init__.py +1 -0
- connectors/base.py +39 -0
- connectors/github.py +273 -0
- connectors/gmail.py +116 -0
- connectors/notion.py +156 -0
- connectors/registry.py +60 -0
- core/__init__.py +1 -0
- core/chunker.py +136 -0
- core/context_builder.py +407 -0
- core/embedder.py +42 -0
- core/llm.py +301 -0
- core/vector_store.py +629 -0
- infrastructure/__init__.py +1 -0
- infrastructure/compose.py +136 -0
- infrastructure/config.py +280 -0
- infrastructure/docker.py +61 -0
- infrastructure/health.py +338 -0
- infrastructure/observability.py +160 -0
- infrastructure/workspace.py +152 -0
- models/__init__.py +1 -0
- models/memory.py +28 -0
- storage/__init__.py +1 -0
- storage/db.py +528 -0
- storage/graph.py +324 -0
- storage/schema.sql +57 -0
- storage/tech_detector.py +117 -0
storage/graph.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
from neo4j import GraphDatabase
|
|
4
|
+
from storage.db import (
|
|
5
|
+
insert_fallback_node,
|
|
6
|
+
insert_fallback_relationship,
|
|
7
|
+
get_fallback_relationships,
|
|
8
|
+
clear_fallback_graph,
|
|
9
|
+
get_all_repositories,
|
|
10
|
+
get_all_documents,
|
|
11
|
+
get_all_emails,
|
|
12
|
+
get_connection
|
|
13
|
+
)
|
|
14
|
+
from storage.tech_detector import detect_tech_for_repo
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("graph_store")
|
|
17
|
+
|
|
18
|
+
class GraphStore:
|
|
19
|
+
_driver = None
|
|
20
|
+
_use_fallback = False
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
"""Initialise driver for Neo4j knowledge graph, falling back to local SQLite if unavailable."""
|
|
24
|
+
if GraphStore._driver is None and not GraphStore._use_fallback:
|
|
25
|
+
uri = os.getenv("NEO4J_URI", "bolt://localhost:7687")
|
|
26
|
+
user = os.getenv("NEO4J_USER", "neo4j")
|
|
27
|
+
password = os.getenv("NEO4J_PASSWORD", "password")
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
driver = GraphDatabase.driver(uri, auth=(user, password))
|
|
31
|
+
driver.verify_connectivity()
|
|
32
|
+
GraphStore._driver = driver
|
|
33
|
+
logger.info("Connected to Neo4j database successfully.")
|
|
34
|
+
print("Neo4j connection successful.")
|
|
35
|
+
except Exception as e:
|
|
36
|
+
GraphStore._use_fallback = True
|
|
37
|
+
logger.warning(f"Neo4j connectivity check failed: {e}. Falling back to SQLite graph storage.")
|
|
38
|
+
print("Neo4j not available. Using local SQLite Graph storage.")
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def driver(self):
|
|
42
|
+
"""Get the active Neo4j driver client instance."""
|
|
43
|
+
return GraphStore._driver
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def is_fallback(self) -> bool:
|
|
47
|
+
"""Boolean flag indicating whether the SQLite fallback graph engine is active."""
|
|
48
|
+
return GraphStore._use_fallback
|
|
49
|
+
|
|
50
|
+
def close(self):
|
|
51
|
+
"""Close the active Neo4j driver connection cleanly."""
|
|
52
|
+
if GraphStore._driver:
|
|
53
|
+
try:
|
|
54
|
+
GraphStore._driver.close()
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
GraphStore._driver = None
|
|
58
|
+
|
|
59
|
+
def clear_graph(self):
|
|
60
|
+
"""Delete all nodes and relationships from both active and fallback graphs."""
|
|
61
|
+
if self.is_fallback:
|
|
62
|
+
clear_fallback_graph()
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
with self.driver.session() as session:
|
|
66
|
+
session.run("MATCH (n) DETACH DELETE n")
|
|
67
|
+
|
|
68
|
+
def insert_node(self, node_id: str, label: str, name: str):
|
|
69
|
+
"""Merge a new node with unique node_id and name into the graph database."""
|
|
70
|
+
if self.is_fallback:
|
|
71
|
+
insert_fallback_node(node_id, label, name)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
with self.driver.session() as session:
|
|
75
|
+
query = f"MERGE (n:{label} {{id: $id}}) SET n.name = $name"
|
|
76
|
+
session.run(query, id=node_id, name=name)
|
|
77
|
+
|
|
78
|
+
def insert_relationship(self, rel_id: str, source_id: str, source_label: str, target_id: str, target_label: str, rel_type: str):
|
|
79
|
+
"""Merge a new directed relationship of rel_type between source and target nodes."""
|
|
80
|
+
if self.is_fallback:
|
|
81
|
+
insert_fallback_relationship(rel_id, source_id, target_id, rel_type)
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
with self.driver.session() as session:
|
|
85
|
+
query = (
|
|
86
|
+
f"MATCH (s:{source_label} {{id: $source_id}}) "
|
|
87
|
+
f"MATCH (t:{target_label} {{id: $target_id}}) "
|
|
88
|
+
f"MERGE (s)-[r:{rel_type}]->(t) "
|
|
89
|
+
f"SET r.id = $rel_id"
|
|
90
|
+
)
|
|
91
|
+
session.run(query, source_id=source_id, target_id=target_id, rel_id=rel_id)
|
|
92
|
+
|
|
93
|
+
def lookup_relationships(self, entity_name: str) -> list:
|
|
94
|
+
"""Find nodes matching entity_name (case-insensitive substring) and return descriptions of their relationships."""
|
|
95
|
+
descriptions = []
|
|
96
|
+
entity_lower = entity_name.lower()
|
|
97
|
+
|
|
98
|
+
if self.is_fallback:
|
|
99
|
+
# First, find nodes in SQLite matching name
|
|
100
|
+
conn = get_connection()
|
|
101
|
+
cursor = conn.cursor()
|
|
102
|
+
cursor.execute("SELECT id, label, name FROM graph_nodes WHERE LOWER(name) LIKE ?", (f"%{entity_lower}%",))
|
|
103
|
+
nodes = cursor.fetchall()
|
|
104
|
+
conn.close()
|
|
105
|
+
|
|
106
|
+
for node_id, label, name in nodes:
|
|
107
|
+
rels = get_fallback_relationships(node_id)
|
|
108
|
+
for r in rels:
|
|
109
|
+
desc = f"{r['source_label']} '{r['source_name']}' {r['type']} {r['target_label']} '{r['target_name']}'"
|
|
110
|
+
descriptions.append(desc)
|
|
111
|
+
return list(set(descriptions))
|
|
112
|
+
|
|
113
|
+
# Query Neo4j
|
|
114
|
+
with self.driver.session() as session:
|
|
115
|
+
query = (
|
|
116
|
+
"MATCH (n) WHERE toLower(n.name) CONTAINS $entity "
|
|
117
|
+
"MATCH (n)-[r]-(m) "
|
|
118
|
+
"RETURN labels(n)[0] AS n_label, n.name AS n_name, type(r) AS rel_type, labels(m)[0] AS m_label, m.name AS m_name, startNode(r) = n AS is_outgoing"
|
|
119
|
+
)
|
|
120
|
+
result = session.run(query, entity=entity_lower)
|
|
121
|
+
for record in result:
|
|
122
|
+
n_label = record["n_label"] or "Node"
|
|
123
|
+
n_name = record["n_name"] or ""
|
|
124
|
+
rel_type = record["rel_type"] or ""
|
|
125
|
+
m_label = record["m_label"] or "Node"
|
|
126
|
+
m_name = record["m_name"] or ""
|
|
127
|
+
|
|
128
|
+
if record["is_outgoing"]:
|
|
129
|
+
desc = f"{n_label} '{n_name}' {rel_type} {m_label} '{m_name}'"
|
|
130
|
+
else:
|
|
131
|
+
desc = f"{m_label} '{m_name}' {rel_type} {n_label} '{n_name}'"
|
|
132
|
+
descriptions.append(desc)
|
|
133
|
+
return list(set(descriptions))
|
|
134
|
+
|
|
135
|
+
def get_node_relationships(self, label: str, name: str) -> list:
|
|
136
|
+
"""Find relationships for a specific node by its label and name, case-insensitively."""
|
|
137
|
+
descriptions = []
|
|
138
|
+
name_lower = name.lower()
|
|
139
|
+
|
|
140
|
+
if self.is_fallback:
|
|
141
|
+
conn = get_connection()
|
|
142
|
+
cursor = conn.cursor()
|
|
143
|
+
# Find the node matching label and name case-insensitively
|
|
144
|
+
cursor.execute(
|
|
145
|
+
"SELECT id, label, name FROM graph_nodes WHERE LOWER(label) = LOWER(?) AND LOWER(name) = LOWER(?)",
|
|
146
|
+
(label, name)
|
|
147
|
+
)
|
|
148
|
+
node = cursor.fetchone()
|
|
149
|
+
if not node:
|
|
150
|
+
# If exact name is not found, try containing it
|
|
151
|
+
cursor.execute(
|
|
152
|
+
"SELECT id, label, name FROM graph_nodes WHERE LOWER(label) = LOWER(?) AND LOWER(name) LIKE ?",
|
|
153
|
+
(label, f"%{name_lower}%")
|
|
154
|
+
)
|
|
155
|
+
nodes = cursor.fetchall()
|
|
156
|
+
else:
|
|
157
|
+
nodes = [node]
|
|
158
|
+
|
|
159
|
+
for node_id, n_lbl, n_nm in nodes:
|
|
160
|
+
rels = get_fallback_relationships(node_id)
|
|
161
|
+
for r in rels:
|
|
162
|
+
desc = f"{r['source_label']} '{r['source_name']}' {r['type']} {r['target_label']} '{r['target_name']}'"
|
|
163
|
+
descriptions.append(desc)
|
|
164
|
+
conn.close()
|
|
165
|
+
return list(set(descriptions))
|
|
166
|
+
|
|
167
|
+
# Query Neo4j
|
|
168
|
+
with self.driver.session() as session:
|
|
169
|
+
query = (
|
|
170
|
+
f"MATCH (n:{label}) WHERE toLower(n.name) = $name "
|
|
171
|
+
"MATCH (n)-[r]-(m) "
|
|
172
|
+
"RETURN labels(n)[0] AS n_label, n.name AS n_name, type(r) AS rel_type, labels(m)[0] AS m_label, m.name AS m_name, startNode(r) = n AS is_outgoing"
|
|
173
|
+
)
|
|
174
|
+
result = session.run(query, name=name_lower)
|
|
175
|
+
records = list(result)
|
|
176
|
+
if not records:
|
|
177
|
+
query_contains = (
|
|
178
|
+
f"MATCH (n:{label}) WHERE toLower(n.name) CONTAINS $name "
|
|
179
|
+
"MATCH (n)-[r]-(m) "
|
|
180
|
+
"RETURN labels(n)[0] AS n_label, n.name AS n_name, type(r) AS rel_type, labels(m)[0] AS m_label, m.name AS m_name, startNode(r) = n AS is_outgoing"
|
|
181
|
+
)
|
|
182
|
+
result_contains = session.run(query_contains, name=name_lower)
|
|
183
|
+
records = list(result_contains)
|
|
184
|
+
|
|
185
|
+
for record in records:
|
|
186
|
+
n_label = record["n_label"] or "Node"
|
|
187
|
+
n_name = record["n_name"] or ""
|
|
188
|
+
rel_type = record["rel_type"] or ""
|
|
189
|
+
m_label = record["m_label"] or "Node"
|
|
190
|
+
m_name = record["m_name"] or ""
|
|
191
|
+
|
|
192
|
+
if record["is_outgoing"]:
|
|
193
|
+
desc = f"{n_label} '{n_name}' {rel_type} {m_label} '{m_name}'"
|
|
194
|
+
else:
|
|
195
|
+
desc = f"{m_label} '{m_name}' {rel_type} {n_label} '{n_name}'"
|
|
196
|
+
descriptions.append(desc)
|
|
197
|
+
return list(set(descriptions))
|
|
198
|
+
|
|
199
|
+
def extract_and_sync_graph(self, repo_names=None):
|
|
200
|
+
"""Extract entities and relationships from SQLite and sync them to Neo4j/Fallback Graph."""
|
|
201
|
+
import time
|
|
202
|
+
import logging
|
|
203
|
+
logger = logging.getLogger("graph_store")
|
|
204
|
+
|
|
205
|
+
logger.info("Starting graph synchronization process...")
|
|
206
|
+
start_time = time.perf_counter()
|
|
207
|
+
|
|
208
|
+
print("Extracting entities and relationships to Graph...")
|
|
209
|
+
|
|
210
|
+
if repo_names is None:
|
|
211
|
+
self.clear_graph()
|
|
212
|
+
else:
|
|
213
|
+
# Incremental clear
|
|
214
|
+
for r_name in repo_names:
|
|
215
|
+
if r_name == "__emails__":
|
|
216
|
+
if self.is_fallback:
|
|
217
|
+
from storage.db import get_connection
|
|
218
|
+
conn = get_connection()
|
|
219
|
+
cursor = conn.cursor()
|
|
220
|
+
cursor.execute("DELETE FROM graph_relationships WHERE source_id LIKE 'Email:%' OR target_id LIKE 'Email:%' OR source_id LIKE 'User:%' OR target_id LIKE 'User:%'")
|
|
221
|
+
cursor.execute("DELETE FROM graph_nodes WHERE label IN ('Email', 'User')")
|
|
222
|
+
conn.commit()
|
|
223
|
+
conn.close()
|
|
224
|
+
else:
|
|
225
|
+
with self.driver.session() as session:
|
|
226
|
+
session.run("MATCH (e:Email) DETACH DELETE e")
|
|
227
|
+
session.run("MATCH (u:User) DETACH DELETE u")
|
|
228
|
+
else:
|
|
229
|
+
repo_prefix = f"Document:{r_name}:"
|
|
230
|
+
repo_node_id = f"Repository:{r_name}"
|
|
231
|
+
if self.is_fallback:
|
|
232
|
+
from storage.db import get_connection
|
|
233
|
+
conn = get_connection()
|
|
234
|
+
cursor = conn.cursor()
|
|
235
|
+
cursor.execute(
|
|
236
|
+
"DELETE FROM graph_relationships WHERE source_id = ? OR target_id = ? OR source_id LIKE ? OR target_id LIKE ?",
|
|
237
|
+
(repo_node_id, repo_node_id, f"{repo_prefix}%", f"{repo_prefix}%")
|
|
238
|
+
)
|
|
239
|
+
cursor.execute(
|
|
240
|
+
"DELETE FROM graph_nodes WHERE id = ? OR id LIKE ?",
|
|
241
|
+
(repo_node_id, f"{repo_prefix}%")
|
|
242
|
+
)
|
|
243
|
+
conn.commit()
|
|
244
|
+
conn.close()
|
|
245
|
+
else:
|
|
246
|
+
with self.driver.session() as session:
|
|
247
|
+
session.run("MATCH (r:Repository {id: $repo_id}) DETACH DELETE r", repo_id=repo_node_id)
|
|
248
|
+
session.run("MATCH (d:Document) WHERE d.id STARTS WITH $prefix DETACH DELETE d", prefix=repo_prefix)
|
|
249
|
+
|
|
250
|
+
node_count = 0
|
|
251
|
+
rel_count = 0
|
|
252
|
+
|
|
253
|
+
# Determine repos to sync
|
|
254
|
+
if repo_names is None:
|
|
255
|
+
repos_to_sync = get_all_repositories()
|
|
256
|
+
else:
|
|
257
|
+
all_repos = get_all_repositories()
|
|
258
|
+
repos_to_sync = [r for r in all_repos if r["repo_name"] in repo_names]
|
|
259
|
+
|
|
260
|
+
# 1. Repositories and detected technologies
|
|
261
|
+
for repo in repos_to_sync:
|
|
262
|
+
repo_name = repo["repo_name"]
|
|
263
|
+
repo_node_id = f"Repository:{repo_name}"
|
|
264
|
+
self.insert_node(repo_node_id, "Repository", repo_name)
|
|
265
|
+
node_count += 1
|
|
266
|
+
|
|
267
|
+
# Detect technology
|
|
268
|
+
techs = detect_tech_for_repo(repo_name)
|
|
269
|
+
for tech in techs:
|
|
270
|
+
tech_node_id = f"Technology:{tech}"
|
|
271
|
+
self.insert_node(tech_node_id, "Technology", tech)
|
|
272
|
+
node_count += 1
|
|
273
|
+
|
|
274
|
+
# Relationship USES
|
|
275
|
+
rel_id = f"{repo_name}-USES-{tech}"
|
|
276
|
+
self.insert_relationship(rel_id, repo_node_id, "Repository", tech_node_id, "Technology", "USES")
|
|
277
|
+
rel_count += 1
|
|
278
|
+
|
|
279
|
+
# 2. Documents inside repositories
|
|
280
|
+
if repo_names is None:
|
|
281
|
+
docs_to_sync = get_all_documents()
|
|
282
|
+
else:
|
|
283
|
+
all_docs = get_all_documents()
|
|
284
|
+
docs_to_sync = [d for d in all_docs if d["repo_name"] in repo_names]
|
|
285
|
+
|
|
286
|
+
for doc in docs_to_sync:
|
|
287
|
+
repo_name = doc["repo_name"]
|
|
288
|
+
file_name = doc["file_name"]
|
|
289
|
+
|
|
290
|
+
doc_node_id = f"Document:{repo_name}:{file_name}"
|
|
291
|
+
self.insert_node(doc_node_id, "Document", file_name)
|
|
292
|
+
node_count += 1
|
|
293
|
+
|
|
294
|
+
repo_node_id = f"Repository:{repo_name}"
|
|
295
|
+
# Relationship CONTAINS
|
|
296
|
+
rel_id = f"{repo_name}-CONTAINS-{file_name}"
|
|
297
|
+
self.insert_relationship(rel_id, repo_node_id, "Repository", doc_node_id, "Document", "CONTAINS")
|
|
298
|
+
rel_count += 1
|
|
299
|
+
|
|
300
|
+
# 3. Emails and senders
|
|
301
|
+
if repo_names is None or "__emails__" in repo_names:
|
|
302
|
+
emails = get_all_emails()
|
|
303
|
+
for email in emails:
|
|
304
|
+
subject = email["subject"] or "No Subject"
|
|
305
|
+
sender = email["sender"] or "Unknown Sender"
|
|
306
|
+
message_id = email["message_id"] or ""
|
|
307
|
+
|
|
308
|
+
email_node_id = f"Email:{message_id}"
|
|
309
|
+
self.insert_node(email_node_id, "Email", subject)
|
|
310
|
+
node_count += 1
|
|
311
|
+
|
|
312
|
+
user_node_id = f"User:{sender}"
|
|
313
|
+
self.insert_node(user_node_id, "User", sender)
|
|
314
|
+
node_count += 1
|
|
315
|
+
|
|
316
|
+
# Relationship SENT_BY
|
|
317
|
+
rel_id = f"{message_id}-SENT_BY-{sender}"
|
|
318
|
+
self.insert_relationship(rel_id, email_node_id, "Email", user_node_id, "User", "SENT_BY")
|
|
319
|
+
rel_count += 1
|
|
320
|
+
|
|
321
|
+
duration = time.perf_counter() - start_time
|
|
322
|
+
target_db = "SQLite Fallback" if self.is_fallback else "Neo4j"
|
|
323
|
+
logger.info(f"Graph sync complete. Synced to {target_db}. Nodes: {node_count}, Relationships: {rel_count} in {duration:.2f}s")
|
|
324
|
+
print(f"Graph Sync Complete. Target: {target_db}. Nodes: {node_count}, Relationships: {rel_count}. Duration: {duration:.2f}s")
|
storage/schema.sql
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS emails (
|
|
2
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3
|
+
message_id TEXT UNIQUE,
|
|
4
|
+
subject TEXT,
|
|
5
|
+
sender TEXT,
|
|
6
|
+
snippet TEXT,
|
|
7
|
+
received_at TEXT
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
CREATE TABLE IF NOT EXISTS repositories (
|
|
12
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
13
|
+
repo_name TEXT UNIQUE,
|
|
14
|
+
description TEXT,
|
|
15
|
+
language TEXT,
|
|
16
|
+
visibility TEXT,
|
|
17
|
+
stars INTEGER,
|
|
18
|
+
forks INTEGER,
|
|
19
|
+
open_issues INTEGER,
|
|
20
|
+
default_branch TEXT,
|
|
21
|
+
updated_at TEXT,
|
|
22
|
+
url TEXT
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS repository_documents (
|
|
26
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
27
|
+
repo_name TEXT,
|
|
28
|
+
file_name TEXT,
|
|
29
|
+
content TEXT,
|
|
30
|
+
source TEXT,
|
|
31
|
+
synced_at TEXT
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS document_chunks (
|
|
35
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
36
|
+
repository_name TEXT,
|
|
37
|
+
document_name TEXT,
|
|
38
|
+
source_type TEXT,
|
|
39
|
+
chunk_text TEXT,
|
|
40
|
+
chunk_index INTEGER,
|
|
41
|
+
created_at TEXT
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS graph_nodes (
|
|
45
|
+
id TEXT PRIMARY KEY,
|
|
46
|
+
label TEXT,
|
|
47
|
+
name TEXT
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS graph_relationships (
|
|
51
|
+
id TEXT PRIMARY KEY,
|
|
52
|
+
source_id TEXT,
|
|
53
|
+
target_id TEXT,
|
|
54
|
+
type TEXT,
|
|
55
|
+
FOREIGN KEY(source_id) REFERENCES graph_nodes(id),
|
|
56
|
+
FOREIGN KEY(target_id) REFERENCES graph_nodes(id)
|
|
57
|
+
);
|
storage/tech_detector.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from storage.db import get_repository_details, get_all_repositories, get_connection
|
|
3
|
+
|
|
4
|
+
TECH_PATTERNS = {
|
|
5
|
+
"Anthropic": [r"\banthropic\b"],
|
|
6
|
+
"Composio": [r"\bcomposio\b"],
|
|
7
|
+
"Docker": [r"\bdocker\b"],
|
|
8
|
+
"Docker Compose": [r"\bdocker compose\b", r"\bdocker-compose\b"],
|
|
9
|
+
"Express": [r"\bexpress\b", r"\bexpress\.js\b", r"\bexpressjs\b"],
|
|
10
|
+
"FastAPI": [r"\bfastapi\b"],
|
|
11
|
+
"Firebase": [r"\bfirebase\b"],
|
|
12
|
+
"Flask": [r"\bflask\b"],
|
|
13
|
+
"Gemini": [r"\bgemini\b"],
|
|
14
|
+
"GraphQL": [r"\bgraphql\b"],
|
|
15
|
+
"Groq": [r"\bgroq\b"],
|
|
16
|
+
"JavaScript": [r"\bjavascript\b", r"\bjs\b"],
|
|
17
|
+
"Kafka": [r"\bkafka\b"],
|
|
18
|
+
"Kubernetes": [r"\bkubernetes\b", r"\bk8s\b"],
|
|
19
|
+
"LangChain": [r"\blangchain\b"],
|
|
20
|
+
"LlamaIndex": [r"\bllamaindex\b"],
|
|
21
|
+
"MongoDB": [r"\bmongodb\b", r"\bmongo\b"],
|
|
22
|
+
"Neo4j": [r"\bneo4j\b"],
|
|
23
|
+
"Next.js": [r"\bnext\.js\b", r"\bnextjs\b"],
|
|
24
|
+
"Node.js": [r"\bnode\.js\b", r"\bnodejs\b", r"\bnode\b"],
|
|
25
|
+
"OpenAI": [r"\bopenai\b"],
|
|
26
|
+
"Plotly": [r"\bplotly\b"],
|
|
27
|
+
"PostgreSQL": [r"\bpostgresql\b", r"\bpostgres\b"],
|
|
28
|
+
"Python": [r"\bpython\b"],
|
|
29
|
+
"Qdrant": [r"\bqdrant\b"],
|
|
30
|
+
"RabbitMQ": [r"\brabbitmq\b"],
|
|
31
|
+
"React": [r"\breact\b", r"\breact\.js\b", r"\breactjs\b"],
|
|
32
|
+
"React Native": [r"\breact native\b"],
|
|
33
|
+
"Redis": [r"\bredis\b"],
|
|
34
|
+
"REST API": [r"\brest[- ]api[s]?\b", r"\brestful\b"],
|
|
35
|
+
"Sentence Transformers": [r"\bsentence[- ]transformers\b", r"\bsentence_transformers\b"],
|
|
36
|
+
"SQLite": [r"\bsqlite\b", r"\bsqlite3\b"],
|
|
37
|
+
"Tailwind CSS": [r"\btailwind\b", r"\btailwindcss\b", r"\btailwind css\b"],
|
|
38
|
+
"TypeScript": [r"\btypescript\b", r"\bts\b"],
|
|
39
|
+
"Vite": [r"\bvite\b"]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Compile patterns for faster regex scanning
|
|
43
|
+
COMPILED_PATTERNS = {
|
|
44
|
+
tech: [re.compile(p, re.IGNORECASE) for p in patterns]
|
|
45
|
+
for tech, patterns in TECH_PATTERNS.items()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
def detect_tech_in_text(text: str) -> list:
|
|
49
|
+
if not text:
|
|
50
|
+
return []
|
|
51
|
+
detected = []
|
|
52
|
+
for tech, patterns in COMPILED_PATTERNS.items():
|
|
53
|
+
for pattern in patterns:
|
|
54
|
+
if pattern.search(text):
|
|
55
|
+
detected.append(tech)
|
|
56
|
+
break
|
|
57
|
+
return detected
|
|
58
|
+
|
|
59
|
+
def detect_tech_for_repo(repo_name: str) -> list:
|
|
60
|
+
details = get_repository_details(repo_name)
|
|
61
|
+
if not details:
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
contents = []
|
|
65
|
+
if details.get("description"):
|
|
66
|
+
contents.append(details["description"])
|
|
67
|
+
|
|
68
|
+
conn = get_connection()
|
|
69
|
+
cursor = conn.cursor()
|
|
70
|
+
cursor.execute("SELECT content FROM repository_documents WHERE LOWER(repo_name) = LOWER(?)", (repo_name,))
|
|
71
|
+
for row in cursor.fetchall():
|
|
72
|
+
if row[0]:
|
|
73
|
+
contents.append(row[0])
|
|
74
|
+
conn.close()
|
|
75
|
+
|
|
76
|
+
combined_text = "\n".join(contents)
|
|
77
|
+
detected = detect_tech_in_text(combined_text)
|
|
78
|
+
return sorted(list(set(detected)))
|
|
79
|
+
|
|
80
|
+
def detect_all_tech() -> list:
|
|
81
|
+
repos = get_all_repositories()
|
|
82
|
+
all_tech = set()
|
|
83
|
+
for repo in repos:
|
|
84
|
+
tech_list = detect_tech_for_repo(repo["repo_name"])
|
|
85
|
+
all_tech.update(tech_list)
|
|
86
|
+
return sorted(list(all_tech))
|
|
87
|
+
|
|
88
|
+
def find_repos_by_tech(tech_name: str) -> list:
|
|
89
|
+
repos = get_all_repositories()
|
|
90
|
+
matching_repos = []
|
|
91
|
+
tech_name_lower = tech_name.lower()
|
|
92
|
+
|
|
93
|
+
for repo in repos:
|
|
94
|
+
repo_name = repo["repo_name"]
|
|
95
|
+
detected = detect_tech_for_repo(repo_name)
|
|
96
|
+
|
|
97
|
+
has_match = False
|
|
98
|
+
for t in detected:
|
|
99
|
+
if t.lower() == tech_name_lower:
|
|
100
|
+
has_match = True
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
if not has_match:
|
|
104
|
+
# Fallback direct substring/regex lookup in full text
|
|
105
|
+
conn = get_connection()
|
|
106
|
+
cursor = conn.cursor()
|
|
107
|
+
cursor.execute("SELECT content FROM repository_documents WHERE LOWER(repo_name) = LOWER(?)", (repo_name,))
|
|
108
|
+
combined_text = (repo.get("description") or "") + "\n" + "\n".join([row[0] for row in cursor.fetchall() if row[0]])
|
|
109
|
+
conn.close()
|
|
110
|
+
|
|
111
|
+
if re.search(r'\b' + re.escape(tech_name_lower) + r'\b', combined_text.lower()):
|
|
112
|
+
has_match = True
|
|
113
|
+
|
|
114
|
+
if has_match:
|
|
115
|
+
matching_repos.append(repo_name)
|
|
116
|
+
|
|
117
|
+
return sorted(matching_repos)
|