kg-mcp 0.1.8__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.
- kg_mcp/__init__.py +5 -0
- kg_mcp/__main__.py +8 -0
- kg_mcp/cli/__init__.py +3 -0
- kg_mcp/cli/setup.py +1100 -0
- kg_mcp/cli/status.py +344 -0
- kg_mcp/codegraph/__init__.py +3 -0
- kg_mcp/codegraph/indexer.py +296 -0
- kg_mcp/codegraph/model.py +170 -0
- kg_mcp/config.py +83 -0
- kg_mcp/kg/__init__.py +3 -0
- kg_mcp/kg/apply_schema.py +93 -0
- kg_mcp/kg/ingest.py +253 -0
- kg_mcp/kg/neo4j.py +155 -0
- kg_mcp/kg/repo.py +756 -0
- kg_mcp/kg/retrieval.py +225 -0
- kg_mcp/kg/schema.cypher +176 -0
- kg_mcp/llm/__init__.py +4 -0
- kg_mcp/llm/client.py +291 -0
- kg_mcp/llm/prompts/__init__.py +8 -0
- kg_mcp/llm/prompts/extractor.py +84 -0
- kg_mcp/llm/prompts/linker.py +117 -0
- kg_mcp/llm/schemas.py +248 -0
- kg_mcp/main.py +195 -0
- kg_mcp/mcp/__init__.py +3 -0
- kg_mcp/mcp/change_schemas.py +140 -0
- kg_mcp/mcp/prompts.py +223 -0
- kg_mcp/mcp/resources.py +218 -0
- kg_mcp/mcp/tools.py +537 -0
- kg_mcp/security/__init__.py +3 -0
- kg_mcp/security/auth.py +121 -0
- kg_mcp/security/origin.py +112 -0
- kg_mcp/utils.py +100 -0
- kg_mcp-0.1.8.dist-info/METADATA +86 -0
- kg_mcp-0.1.8.dist-info/RECORD +36 -0
- kg_mcp-0.1.8.dist-info/WHEEL +4 -0
- kg_mcp-0.1.8.dist-info/entry_points.txt +4 -0
kg_mcp/kg/repo.py
ADDED
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Repository layer for Neo4j queries.
|
|
3
|
+
Provides typed query functions for CRUD operations on the knowledge graph.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from kg_mcp.kg.neo4j import get_neo4j_client
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class KGRepository:
|
|
17
|
+
"""Repository for knowledge graph operations."""
|
|
18
|
+
|
|
19
|
+
def __init__(self):
|
|
20
|
+
self.client = get_neo4j_client()
|
|
21
|
+
|
|
22
|
+
# =========================================================================
|
|
23
|
+
# Project Operations
|
|
24
|
+
# =========================================================================
|
|
25
|
+
|
|
26
|
+
async def get_or_create_project(self, project_id: str, name: Optional[str] = None) -> Dict[str, Any]:
|
|
27
|
+
"""Get or create a project node."""
|
|
28
|
+
query = """
|
|
29
|
+
MERGE (p:Project {id: $project_id})
|
|
30
|
+
ON CREATE SET
|
|
31
|
+
p.name = $name,
|
|
32
|
+
p.created_at = datetime(),
|
|
33
|
+
p.updated_at = datetime()
|
|
34
|
+
ON MATCH SET
|
|
35
|
+
p.updated_at = datetime()
|
|
36
|
+
RETURN p {.*} as project
|
|
37
|
+
"""
|
|
38
|
+
result = await self.client.execute_query(
|
|
39
|
+
query,
|
|
40
|
+
{"project_id": project_id, "name": name or project_id},
|
|
41
|
+
)
|
|
42
|
+
return result[0]["project"] if result else {}
|
|
43
|
+
|
|
44
|
+
# =========================================================================
|
|
45
|
+
# Interaction Operations
|
|
46
|
+
# =========================================================================
|
|
47
|
+
|
|
48
|
+
async def create_interaction(
|
|
49
|
+
self,
|
|
50
|
+
project_id: str,
|
|
51
|
+
user_text: str,
|
|
52
|
+
assistant_text: Optional[str] = None,
|
|
53
|
+
tags: Optional[List[str]] = None,
|
|
54
|
+
) -> Dict[str, Any]:
|
|
55
|
+
"""Create a new interaction node."""
|
|
56
|
+
interaction_id = str(uuid4())
|
|
57
|
+
query = """
|
|
58
|
+
MATCH (p:Project {id: $project_id})
|
|
59
|
+
CREATE (i:Interaction {
|
|
60
|
+
id: $interaction_id,
|
|
61
|
+
user_text: $user_text,
|
|
62
|
+
assistant_text: $assistant_text,
|
|
63
|
+
tags: $tags,
|
|
64
|
+
project_id: $project_id,
|
|
65
|
+
timestamp: datetime(),
|
|
66
|
+
created_at: datetime()
|
|
67
|
+
})
|
|
68
|
+
CREATE (i)-[:IN_PROJECT]->(p)
|
|
69
|
+
RETURN i {.*} as interaction
|
|
70
|
+
"""
|
|
71
|
+
result = await self.client.execute_query(
|
|
72
|
+
query,
|
|
73
|
+
{
|
|
74
|
+
"project_id": project_id,
|
|
75
|
+
"interaction_id": interaction_id,
|
|
76
|
+
"user_text": user_text,
|
|
77
|
+
"assistant_text": assistant_text,
|
|
78
|
+
"tags": tags or [],
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
return result[0]["interaction"] if result else {"id": interaction_id}
|
|
82
|
+
|
|
83
|
+
async def get_recent_interactions(
|
|
84
|
+
self, project_id: str, limit: int = 10
|
|
85
|
+
) -> List[Dict[str, Any]]:
|
|
86
|
+
"""Get recent interactions for a project."""
|
|
87
|
+
query = """
|
|
88
|
+
MATCH (i:Interaction {project_id: $project_id})
|
|
89
|
+
RETURN i {.*} as interaction
|
|
90
|
+
ORDER BY i.timestamp DESC
|
|
91
|
+
LIMIT $limit
|
|
92
|
+
"""
|
|
93
|
+
result = await self.client.execute_query(
|
|
94
|
+
query, {"project_id": project_id, "limit": limit}
|
|
95
|
+
)
|
|
96
|
+
return [r["interaction"] for r in result]
|
|
97
|
+
|
|
98
|
+
# =========================================================================
|
|
99
|
+
# Goal Operations
|
|
100
|
+
# =========================================================================
|
|
101
|
+
|
|
102
|
+
async def upsert_goal(
|
|
103
|
+
self,
|
|
104
|
+
project_id: str,
|
|
105
|
+
title: str,
|
|
106
|
+
description: Optional[str] = None,
|
|
107
|
+
status: str = "active",
|
|
108
|
+
priority: int = 2,
|
|
109
|
+
goal_id: Optional[str] = None,
|
|
110
|
+
) -> Dict[str, Any]:
|
|
111
|
+
"""Upsert a goal node."""
|
|
112
|
+
goal_id = goal_id or str(uuid4())
|
|
113
|
+
query = """
|
|
114
|
+
MATCH (p:Project {id: $project_id})
|
|
115
|
+
MERGE (g:Goal {project_id: $project_id, title: $title})
|
|
116
|
+
ON CREATE SET
|
|
117
|
+
g.id = $goal_id,
|
|
118
|
+
g.description = $description,
|
|
119
|
+
g.status = $status,
|
|
120
|
+
g.priority = $priority,
|
|
121
|
+
g.created_at = datetime(),
|
|
122
|
+
g.updated_at = datetime()
|
|
123
|
+
ON MATCH SET
|
|
124
|
+
g.description = COALESCE($description, g.description),
|
|
125
|
+
g.status = $status,
|
|
126
|
+
g.priority = $priority,
|
|
127
|
+
g.updated_at = datetime()
|
|
128
|
+
MERGE (p)-[:HAS_GOAL]->(g)
|
|
129
|
+
RETURN g {.*} as goal
|
|
130
|
+
"""
|
|
131
|
+
result = await self.client.execute_query(
|
|
132
|
+
query,
|
|
133
|
+
{
|
|
134
|
+
"project_id": project_id,
|
|
135
|
+
"goal_id": goal_id,
|
|
136
|
+
"title": title,
|
|
137
|
+
"description": description,
|
|
138
|
+
"status": status,
|
|
139
|
+
"priority": priority,
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
return result[0]["goal"] if result else {"id": goal_id, "title": title}
|
|
143
|
+
|
|
144
|
+
async def get_active_goals(self, project_id: str) -> List[Dict[str, Any]]:
|
|
145
|
+
"""Get all active goals for a project."""
|
|
146
|
+
query = """
|
|
147
|
+
MATCH (g:Goal {project_id: $project_id, status: 'active'})
|
|
148
|
+
OPTIONAL MATCH (g)-[:HAS_CONSTRAINT]->(c:Constraint)
|
|
149
|
+
OPTIONAL MATCH (g)-[:HAS_STRATEGY]->(s:Strategy)
|
|
150
|
+
OPTIONAL MATCH (g)-[:HAS_ACCEPTANCE_CRITERIA]->(ac:AcceptanceCriteria)
|
|
151
|
+
WITH g,
|
|
152
|
+
collect(DISTINCT c {.*}) as constraints,
|
|
153
|
+
collect(DISTINCT s {.*}) as strategies,
|
|
154
|
+
collect(DISTINCT ac {.*}) as acceptance_criteria
|
|
155
|
+
RETURN g {
|
|
156
|
+
.*,
|
|
157
|
+
constraints: constraints,
|
|
158
|
+
strategies: strategies,
|
|
159
|
+
acceptance_criteria: acceptance_criteria
|
|
160
|
+
} as goal
|
|
161
|
+
ORDER BY g.priority ASC, g.created_at DESC
|
|
162
|
+
"""
|
|
163
|
+
result = await self.client.execute_query(query, {"project_id": project_id})
|
|
164
|
+
return [r["goal"] for r in result]
|
|
165
|
+
|
|
166
|
+
async def get_all_goals(self, project_id: str) -> List[Dict[str, Any]]:
|
|
167
|
+
"""Get all goals for a project."""
|
|
168
|
+
query = """
|
|
169
|
+
MATCH (g:Goal {project_id: $project_id})
|
|
170
|
+
RETURN g {.*} as goal
|
|
171
|
+
ORDER BY g.priority ASC, g.created_at DESC
|
|
172
|
+
"""
|
|
173
|
+
result = await self.client.execute_query(query, {"project_id": project_id})
|
|
174
|
+
return [r["goal"] for r in result]
|
|
175
|
+
|
|
176
|
+
async def link_interaction_to_goal(
|
|
177
|
+
self, interaction_id: str, goal_id: str
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Create PRODUCED relationship between interaction and goal."""
|
|
180
|
+
query = """
|
|
181
|
+
MATCH (i:Interaction {id: $interaction_id})
|
|
182
|
+
MATCH (g:Goal {id: $goal_id})
|
|
183
|
+
MERGE (i)-[:PRODUCED]->(g)
|
|
184
|
+
"""
|
|
185
|
+
await self.client.execute_query(
|
|
186
|
+
query, {"interaction_id": interaction_id, "goal_id": goal_id}
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# =========================================================================
|
|
190
|
+
# Constraint Operations
|
|
191
|
+
# =========================================================================
|
|
192
|
+
|
|
193
|
+
async def upsert_constraint(
|
|
194
|
+
self,
|
|
195
|
+
project_id: str,
|
|
196
|
+
constraint_type: str,
|
|
197
|
+
description: str,
|
|
198
|
+
severity: str = "must",
|
|
199
|
+
goal_id: Optional[str] = None,
|
|
200
|
+
) -> Dict[str, Any]:
|
|
201
|
+
"""Upsert a constraint node."""
|
|
202
|
+
constraint_id = str(uuid4())
|
|
203
|
+
query = """
|
|
204
|
+
MERGE (c:Constraint {project_id: $project_id, description: $description})
|
|
205
|
+
ON CREATE SET
|
|
206
|
+
c.id = $constraint_id,
|
|
207
|
+
c.type = $type,
|
|
208
|
+
c.severity = $severity,
|
|
209
|
+
c.created_at = datetime(),
|
|
210
|
+
c.updated_at = datetime()
|
|
211
|
+
ON MATCH SET
|
|
212
|
+
c.severity = $severity,
|
|
213
|
+
c.updated_at = datetime()
|
|
214
|
+
RETURN c {.*} as constraint
|
|
215
|
+
"""
|
|
216
|
+
result = await self.client.execute_query(
|
|
217
|
+
query,
|
|
218
|
+
{
|
|
219
|
+
"project_id": project_id,
|
|
220
|
+
"constraint_id": constraint_id,
|
|
221
|
+
"type": constraint_type,
|
|
222
|
+
"description": description,
|
|
223
|
+
"severity": severity,
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
constraint = result[0]["constraint"] if result else {"id": constraint_id}
|
|
227
|
+
|
|
228
|
+
# Link to goal if provided
|
|
229
|
+
if goal_id:
|
|
230
|
+
await self.client.execute_query(
|
|
231
|
+
"""
|
|
232
|
+
MATCH (g:Goal {id: $goal_id})
|
|
233
|
+
MATCH (c:Constraint {id: $constraint_id})
|
|
234
|
+
MERGE (g)-[:HAS_CONSTRAINT]->(c)
|
|
235
|
+
""",
|
|
236
|
+
{"goal_id": goal_id, "constraint_id": constraint["id"]},
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return constraint
|
|
240
|
+
|
|
241
|
+
# =========================================================================
|
|
242
|
+
# Preference Operations
|
|
243
|
+
# =========================================================================
|
|
244
|
+
|
|
245
|
+
async def upsert_preference(
|
|
246
|
+
self,
|
|
247
|
+
user_id: str,
|
|
248
|
+
category: str,
|
|
249
|
+
preference: str,
|
|
250
|
+
strength: str = "prefer",
|
|
251
|
+
) -> Dict[str, Any]:
|
|
252
|
+
"""Upsert a preference node."""
|
|
253
|
+
preference_id = str(uuid4())
|
|
254
|
+
query = """
|
|
255
|
+
MERGE (p:Preference {user_id: $user_id, category: $category, preference: $preference})
|
|
256
|
+
ON CREATE SET
|
|
257
|
+
p.id = $preference_id,
|
|
258
|
+
p.strength = $strength,
|
|
259
|
+
p.created_at = datetime(),
|
|
260
|
+
p.updated_at = datetime()
|
|
261
|
+
ON MATCH SET
|
|
262
|
+
p.strength = $strength,
|
|
263
|
+
p.updated_at = datetime()
|
|
264
|
+
RETURN p {.*} as preference
|
|
265
|
+
"""
|
|
266
|
+
result = await self.client.execute_query(
|
|
267
|
+
query,
|
|
268
|
+
{
|
|
269
|
+
"user_id": user_id,
|
|
270
|
+
"preference_id": preference_id,
|
|
271
|
+
"category": category,
|
|
272
|
+
"preference": preference,
|
|
273
|
+
"strength": strength,
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
return result[0]["preference"] if result else {"id": preference_id}
|
|
277
|
+
|
|
278
|
+
async def get_preferences(self, user_id: str) -> List[Dict[str, Any]]:
|
|
279
|
+
"""Get all preferences for a user."""
|
|
280
|
+
query = """
|
|
281
|
+
MATCH (p:Preference {user_id: $user_id})
|
|
282
|
+
RETURN p {.*} as preference
|
|
283
|
+
ORDER BY p.category
|
|
284
|
+
"""
|
|
285
|
+
result = await self.client.execute_query(query, {"user_id": user_id})
|
|
286
|
+
return [r["preference"] for r in result]
|
|
287
|
+
|
|
288
|
+
# =========================================================================
|
|
289
|
+
# PainPoint Operations
|
|
290
|
+
# =========================================================================
|
|
291
|
+
|
|
292
|
+
async def upsert_painpoint(
|
|
293
|
+
self,
|
|
294
|
+
project_id: str,
|
|
295
|
+
description: str,
|
|
296
|
+
severity: str = "medium",
|
|
297
|
+
related_goal_id: Optional[str] = None,
|
|
298
|
+
interaction_id: Optional[str] = None,
|
|
299
|
+
) -> Dict[str, Any]:
|
|
300
|
+
"""Upsert a pain point node."""
|
|
301
|
+
painpoint_id = str(uuid4())
|
|
302
|
+
query = """
|
|
303
|
+
MERGE (pp:PainPoint {project_id: $project_id, description: $description})
|
|
304
|
+
ON CREATE SET
|
|
305
|
+
pp.id = $painpoint_id,
|
|
306
|
+
pp.severity = $severity,
|
|
307
|
+
pp.resolved = false,
|
|
308
|
+
pp.created_at = datetime(),
|
|
309
|
+
pp.updated_at = datetime()
|
|
310
|
+
ON MATCH SET
|
|
311
|
+
pp.severity = $severity,
|
|
312
|
+
pp.updated_at = datetime()
|
|
313
|
+
RETURN pp {.*} as painpoint
|
|
314
|
+
"""
|
|
315
|
+
result = await self.client.execute_query(
|
|
316
|
+
query,
|
|
317
|
+
{
|
|
318
|
+
"project_id": project_id,
|
|
319
|
+
"painpoint_id": painpoint_id,
|
|
320
|
+
"description": description,
|
|
321
|
+
"severity": severity,
|
|
322
|
+
},
|
|
323
|
+
)
|
|
324
|
+
painpoint = result[0]["painpoint"] if result else {"id": painpoint_id}
|
|
325
|
+
|
|
326
|
+
# Link to goal if provided
|
|
327
|
+
if related_goal_id:
|
|
328
|
+
await self.client.execute_query(
|
|
329
|
+
"""
|
|
330
|
+
MATCH (g:Goal {id: $goal_id})
|
|
331
|
+
MATCH (pp:PainPoint {id: $painpoint_id})
|
|
332
|
+
MERGE (g)-[:BLOCKED_BY]->(pp)
|
|
333
|
+
""",
|
|
334
|
+
{"goal_id": related_goal_id, "painpoint_id": painpoint["id"]},
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Link to interaction if provided
|
|
338
|
+
if interaction_id:
|
|
339
|
+
await self.client.execute_query(
|
|
340
|
+
"""
|
|
341
|
+
MATCH (i:Interaction {id: $interaction_id})
|
|
342
|
+
MATCH (pp:PainPoint {id: $painpoint_id})
|
|
343
|
+
MERGE (pp)-[:OBSERVED_IN]->(i)
|
|
344
|
+
""",
|
|
345
|
+
{"interaction_id": interaction_id, "painpoint_id": painpoint["id"]},
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return painpoint
|
|
349
|
+
|
|
350
|
+
async def get_open_painpoints(self, project_id: str) -> List[Dict[str, Any]]:
|
|
351
|
+
"""Get unresolved pain points for a project."""
|
|
352
|
+
query = """
|
|
353
|
+
MATCH (pp:PainPoint {project_id: $project_id, resolved: false})
|
|
354
|
+
OPTIONAL MATCH (pp)<-[:BLOCKED_BY]-(g:Goal)
|
|
355
|
+
WITH pp, pp.severity as severity, collect(DISTINCT g.title) as blocking_goals
|
|
356
|
+
RETURN pp {
|
|
357
|
+
.*,
|
|
358
|
+
blocking_goals: blocking_goals
|
|
359
|
+
} as painpoint
|
|
360
|
+
ORDER BY
|
|
361
|
+
CASE severity
|
|
362
|
+
WHEN 'critical' THEN 1
|
|
363
|
+
WHEN 'high' THEN 2
|
|
364
|
+
WHEN 'medium' THEN 3
|
|
365
|
+
ELSE 4
|
|
366
|
+
END
|
|
367
|
+
"""
|
|
368
|
+
result = await self.client.execute_query(query, {"project_id": project_id})
|
|
369
|
+
return [r["painpoint"] for r in result]
|
|
370
|
+
|
|
371
|
+
# =========================================================================
|
|
372
|
+
# Strategy Operations
|
|
373
|
+
# =========================================================================
|
|
374
|
+
|
|
375
|
+
async def upsert_strategy(
|
|
376
|
+
self,
|
|
377
|
+
project_id: str,
|
|
378
|
+
title: str,
|
|
379
|
+
approach: str,
|
|
380
|
+
rationale: Optional[str] = None,
|
|
381
|
+
outcome: Optional[str] = None,
|
|
382
|
+
outcome_reason: Optional[str] = None,
|
|
383
|
+
related_goal_id: Optional[str] = None,
|
|
384
|
+
) -> Dict[str, Any]:
|
|
385
|
+
"""Upsert a strategy node."""
|
|
386
|
+
strategy_id = str(uuid4())
|
|
387
|
+
query = """
|
|
388
|
+
MERGE (s:Strategy {project_id: $project_id, title: $title})
|
|
389
|
+
ON CREATE SET
|
|
390
|
+
s.id = $strategy_id,
|
|
391
|
+
s.approach = $approach,
|
|
392
|
+
s.rationale = $rationale,
|
|
393
|
+
s.outcome = $outcome,
|
|
394
|
+
s.outcome_reason = $outcome_reason,
|
|
395
|
+
s.created_at = datetime(),
|
|
396
|
+
s.updated_at = datetime()
|
|
397
|
+
ON MATCH SET
|
|
398
|
+
s.approach = $approach,
|
|
399
|
+
s.rationale = COALESCE($rationale, s.rationale),
|
|
400
|
+
s.outcome = COALESCE($outcome, s.outcome),
|
|
401
|
+
s.outcome_reason = COALESCE($outcome_reason, s.outcome_reason),
|
|
402
|
+
s.updated_at = datetime()
|
|
403
|
+
RETURN s {.*} as strategy
|
|
404
|
+
"""
|
|
405
|
+
result = await self.client.execute_query(
|
|
406
|
+
query,
|
|
407
|
+
{
|
|
408
|
+
"project_id": project_id,
|
|
409
|
+
"strategy_id": strategy_id,
|
|
410
|
+
"title": title,
|
|
411
|
+
"approach": approach,
|
|
412
|
+
"rationale": rationale,
|
|
413
|
+
"outcome": outcome,
|
|
414
|
+
"outcome_reason": outcome_reason,
|
|
415
|
+
},
|
|
416
|
+
)
|
|
417
|
+
strategy = result[0]["strategy"] if result else {"id": strategy_id}
|
|
418
|
+
|
|
419
|
+
# Link to goal if provided
|
|
420
|
+
if related_goal_id:
|
|
421
|
+
await self.client.execute_query(
|
|
422
|
+
"""
|
|
423
|
+
MATCH (g:Goal {id: $goal_id})
|
|
424
|
+
MATCH (s:Strategy {id: $strategy_id})
|
|
425
|
+
MERGE (g)-[:HAS_STRATEGY]->(s)
|
|
426
|
+
""",
|
|
427
|
+
{"goal_id": related_goal_id, "strategy_id": strategy["id"]},
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
return strategy
|
|
431
|
+
|
|
432
|
+
# =========================================================================
|
|
433
|
+
# CodeArtifact Operations
|
|
434
|
+
# =========================================================================
|
|
435
|
+
|
|
436
|
+
async def upsert_code_artifact(
|
|
437
|
+
self,
|
|
438
|
+
project_id: str,
|
|
439
|
+
path: str,
|
|
440
|
+
kind: str = "file",
|
|
441
|
+
language: Optional[str] = None,
|
|
442
|
+
symbol_fqn: Optional[str] = None,
|
|
443
|
+
start_line: Optional[int] = None,
|
|
444
|
+
end_line: Optional[int] = None,
|
|
445
|
+
git_commit: Optional[str] = None,
|
|
446
|
+
content_hash: Optional[str] = None,
|
|
447
|
+
related_goal_ids: Optional[List[str]] = None,
|
|
448
|
+
) -> Dict[str, Any]:
|
|
449
|
+
"""Upsert a code artifact node."""
|
|
450
|
+
artifact_id = str(uuid4())
|
|
451
|
+
query = """
|
|
452
|
+
MERGE (ca:CodeArtifact {project_id: $project_id, path: $path})
|
|
453
|
+
ON CREATE SET
|
|
454
|
+
ca.id = $artifact_id,
|
|
455
|
+
ca.kind = $kind,
|
|
456
|
+
ca.language = $language,
|
|
457
|
+
ca.start_line = $start_line,
|
|
458
|
+
ca.end_line = $end_line,
|
|
459
|
+
ca.git_commit = $git_commit,
|
|
460
|
+
ca.content_hash = $content_hash,
|
|
461
|
+
ca.created_at = datetime(),
|
|
462
|
+
ca.updated_at = datetime()
|
|
463
|
+
ON MATCH SET
|
|
464
|
+
ca.kind = $kind,
|
|
465
|
+
ca.language = COALESCE($language, ca.language),
|
|
466
|
+
ca.start_line = COALESCE($start_line, ca.start_line),
|
|
467
|
+
ca.end_line = COALESCE($end_line, ca.end_line),
|
|
468
|
+
ca.git_commit = COALESCE($git_commit, ca.git_commit),
|
|
469
|
+
ca.content_hash = COALESCE($content_hash, ca.content_hash),
|
|
470
|
+
ca.updated_at = datetime()
|
|
471
|
+
RETURN ca {.*} as artifact
|
|
472
|
+
"""
|
|
473
|
+
result = await self.client.execute_query(
|
|
474
|
+
query,
|
|
475
|
+
{
|
|
476
|
+
"project_id": project_id,
|
|
477
|
+
"artifact_id": artifact_id,
|
|
478
|
+
"path": path,
|
|
479
|
+
"kind": kind,
|
|
480
|
+
"language": language,
|
|
481
|
+
"start_line": start_line,
|
|
482
|
+
"end_line": end_line,
|
|
483
|
+
"git_commit": git_commit,
|
|
484
|
+
"content_hash": content_hash,
|
|
485
|
+
},
|
|
486
|
+
)
|
|
487
|
+
artifact = result[0]["artifact"] if result else {"id": artifact_id, "path": path}
|
|
488
|
+
|
|
489
|
+
# Create symbol if FQN provided
|
|
490
|
+
if symbol_fqn:
|
|
491
|
+
await self.upsert_symbol(artifact["id"], symbol_fqn, kind)
|
|
492
|
+
|
|
493
|
+
# Link to goals if provided
|
|
494
|
+
if related_goal_ids:
|
|
495
|
+
for goal_id in related_goal_ids:
|
|
496
|
+
await self.client.execute_query(
|
|
497
|
+
"""
|
|
498
|
+
MATCH (g:Goal {id: $goal_id})
|
|
499
|
+
MATCH (ca:CodeArtifact {id: $artifact_id})
|
|
500
|
+
MERGE (g)-[:IMPLEMENTED_BY]->(ca)
|
|
501
|
+
""",
|
|
502
|
+
{"goal_id": goal_id, "artifact_id": artifact["id"]},
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
return artifact
|
|
506
|
+
|
|
507
|
+
async def upsert_symbol(
|
|
508
|
+
self,
|
|
509
|
+
artifact_id: str,
|
|
510
|
+
fqn: str,
|
|
511
|
+
kind: str = "function",
|
|
512
|
+
name: Optional[str] = None,
|
|
513
|
+
line_start: Optional[int] = None,
|
|
514
|
+
line_end: Optional[int] = None,
|
|
515
|
+
signature: Optional[str] = None,
|
|
516
|
+
change_type: Optional[str] = None,
|
|
517
|
+
) -> Dict[str, Any]:
|
|
518
|
+
"""
|
|
519
|
+
Upsert a symbol node with full details and link to artifact.
|
|
520
|
+
|
|
521
|
+
Args:
|
|
522
|
+
artifact_id: ID of the parent CodeArtifact
|
|
523
|
+
fqn: Fully qualified name (e.g., "src/utils.py:calculate_tax")
|
|
524
|
+
kind: Symbol type: function, method, class, variable
|
|
525
|
+
name: Symbol name (extracted from fqn if not provided)
|
|
526
|
+
line_start: Starting line number (1-indexed)
|
|
527
|
+
line_end: Ending line number (1-indexed)
|
|
528
|
+
signature: Full signature (e.g., "def calculate_tax(income: float) -> float")
|
|
529
|
+
change_type: What happened: added, modified, deleted, renamed
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
The created/updated symbol node
|
|
533
|
+
"""
|
|
534
|
+
symbol_id = str(uuid4())
|
|
535
|
+
# Extract name from fqn if not provided
|
|
536
|
+
if name is None:
|
|
537
|
+
name = fqn.split(":")[-1] if ":" in fqn else fqn.split(".")[-1] if "." in fqn else fqn
|
|
538
|
+
|
|
539
|
+
query = """
|
|
540
|
+
MATCH (ca:CodeArtifact {id: $artifact_id})
|
|
541
|
+
MERGE (s:Symbol {fqn: $fqn})
|
|
542
|
+
ON CREATE SET
|
|
543
|
+
s.id = $symbol_id,
|
|
544
|
+
s.name = $name,
|
|
545
|
+
s.kind = $kind,
|
|
546
|
+
s.artifact_id = $artifact_id,
|
|
547
|
+
s.line_start = $line_start,
|
|
548
|
+
s.line_end = $line_end,
|
|
549
|
+
s.signature = $signature,
|
|
550
|
+
s.change_type = $change_type,
|
|
551
|
+
s.created_at = datetime(),
|
|
552
|
+
s.updated_at = datetime()
|
|
553
|
+
ON MATCH SET
|
|
554
|
+
s.name = $name,
|
|
555
|
+
s.kind = $kind,
|
|
556
|
+
s.artifact_id = $artifact_id,
|
|
557
|
+
s.line_start = COALESCE($line_start, s.line_start),
|
|
558
|
+
s.line_end = COALESCE($line_end, s.line_end),
|
|
559
|
+
s.signature = COALESCE($signature, s.signature),
|
|
560
|
+
s.change_type = $change_type,
|
|
561
|
+
s.updated_at = datetime()
|
|
562
|
+
MERGE (ca)-[:CONTAINS]->(s)
|
|
563
|
+
RETURN s {.*} as symbol
|
|
564
|
+
"""
|
|
565
|
+
result = await self.client.execute_query(
|
|
566
|
+
query,
|
|
567
|
+
{
|
|
568
|
+
"artifact_id": artifact_id,
|
|
569
|
+
"symbol_id": symbol_id,
|
|
570
|
+
"fqn": fqn,
|
|
571
|
+
"name": name,
|
|
572
|
+
"kind": kind,
|
|
573
|
+
"line_start": line_start,
|
|
574
|
+
"line_end": line_end,
|
|
575
|
+
"signature": signature,
|
|
576
|
+
"change_type": change_type,
|
|
577
|
+
},
|
|
578
|
+
)
|
|
579
|
+
return result[0]["symbol"] if result else {"id": symbol_id, "fqn": fqn}
|
|
580
|
+
|
|
581
|
+
async def get_artifacts_for_goal(self, goal_id: str) -> List[Dict[str, Any]]:
|
|
582
|
+
"""Get code artifacts implementing a goal."""
|
|
583
|
+
query = """
|
|
584
|
+
MATCH (g:Goal {id: $goal_id})-[:IMPLEMENTED_BY]->(ca:CodeArtifact)
|
|
585
|
+
OPTIONAL MATCH (ca)-[:CONTAINS]->(s:Symbol)
|
|
586
|
+
WITH ca, collect(DISTINCT s {.*}) as symbols
|
|
587
|
+
RETURN ca {
|
|
588
|
+
.*,
|
|
589
|
+
symbols: symbols
|
|
590
|
+
} as artifact
|
|
591
|
+
"""
|
|
592
|
+
result = await self.client.execute_query(query, {"goal_id": goal_id})
|
|
593
|
+
return [r["artifact"] for r in result]
|
|
594
|
+
|
|
595
|
+
# =========================================================================
|
|
596
|
+
# Search Operations
|
|
597
|
+
# =========================================================================
|
|
598
|
+
|
|
599
|
+
async def fulltext_search(
|
|
600
|
+
self,
|
|
601
|
+
project_id: str,
|
|
602
|
+
query: str,
|
|
603
|
+
node_types: Optional[List[str]] = None,
|
|
604
|
+
limit: int = 20,
|
|
605
|
+
) -> List[Dict[str, Any]]:
|
|
606
|
+
"""
|
|
607
|
+
Perform fulltext search across multiple node types.
|
|
608
|
+
|
|
609
|
+
Args:
|
|
610
|
+
project_id: Project to search within
|
|
611
|
+
query: Search query
|
|
612
|
+
node_types: Types to search (Goal, PainPoint, Strategy, etc.)
|
|
613
|
+
limit: Maximum results
|
|
614
|
+
|
|
615
|
+
Returns:
|
|
616
|
+
List of matching nodes with scores
|
|
617
|
+
"""
|
|
618
|
+
results = []
|
|
619
|
+
|
|
620
|
+
# Search goals
|
|
621
|
+
if not node_types or "Goal" in node_types:
|
|
622
|
+
goal_query = """
|
|
623
|
+
CALL db.index.fulltext.queryNodes('goal_fulltext', $query) YIELD node, score
|
|
624
|
+
WHERE node.project_id = $project_id
|
|
625
|
+
RETURN 'Goal' as type, node {.*} as data, score
|
|
626
|
+
LIMIT $limit
|
|
627
|
+
"""
|
|
628
|
+
try:
|
|
629
|
+
goal_results = await self.client.execute_query(
|
|
630
|
+
goal_query, {"project_id": project_id, "query": query, "limit": limit}
|
|
631
|
+
)
|
|
632
|
+
results.extend(goal_results)
|
|
633
|
+
except Exception as e:
|
|
634
|
+
logger.warning(f"Goal fulltext search failed: {e}")
|
|
635
|
+
|
|
636
|
+
# Search pain points
|
|
637
|
+
if not node_types or "PainPoint" in node_types:
|
|
638
|
+
pp_query = """
|
|
639
|
+
CALL db.index.fulltext.queryNodes('painpoint_fulltext', $query) YIELD node, score
|
|
640
|
+
WHERE node.project_id = $project_id
|
|
641
|
+
RETURN 'PainPoint' as type, node {.*} as data, score
|
|
642
|
+
LIMIT $limit
|
|
643
|
+
"""
|
|
644
|
+
try:
|
|
645
|
+
pp_results = await self.client.execute_query(
|
|
646
|
+
pp_query, {"project_id": project_id, "query": query, "limit": limit}
|
|
647
|
+
)
|
|
648
|
+
results.extend(pp_results)
|
|
649
|
+
except Exception as e:
|
|
650
|
+
logger.warning(f"PainPoint fulltext search failed: {e}")
|
|
651
|
+
|
|
652
|
+
# Search strategies
|
|
653
|
+
if not node_types or "Strategy" in node_types:
|
|
654
|
+
strategy_query = """
|
|
655
|
+
CALL db.index.fulltext.queryNodes('strategy_fulltext', $query) YIELD node, score
|
|
656
|
+
WHERE node.project_id = $project_id
|
|
657
|
+
RETURN 'Strategy' as type, node {.*} as data, score
|
|
658
|
+
LIMIT $limit
|
|
659
|
+
"""
|
|
660
|
+
try:
|
|
661
|
+
strategy_results = await self.client.execute_query(
|
|
662
|
+
strategy_query, {"project_id": project_id, "query": query, "limit": limit}
|
|
663
|
+
)
|
|
664
|
+
results.extend(strategy_results)
|
|
665
|
+
except Exception as e:
|
|
666
|
+
logger.warning(f"Strategy fulltext search failed: {e}")
|
|
667
|
+
|
|
668
|
+
# Sort by score and limit
|
|
669
|
+
results.sort(key=lambda x: x.get("score", 0), reverse=True)
|
|
670
|
+
return results[:limit]
|
|
671
|
+
|
|
672
|
+
# =========================================================================
|
|
673
|
+
# Impact Analysis Operations
|
|
674
|
+
# =========================================================================
|
|
675
|
+
|
|
676
|
+
async def get_impact_for_artifacts(
|
|
677
|
+
self, project_id: str, paths: List[str]
|
|
678
|
+
) -> Dict[str, Any]:
|
|
679
|
+
"""
|
|
680
|
+
Analyze impact of changes to specified file paths.
|
|
681
|
+
|
|
682
|
+
Returns goals, tests, and strategies that might be affected.
|
|
683
|
+
"""
|
|
684
|
+
query = """
|
|
685
|
+
MATCH (ca:CodeArtifact)
|
|
686
|
+
WHERE ca.project_id = $project_id AND ca.path IN $paths
|
|
687
|
+
|
|
688
|
+
// Find implementing goals
|
|
689
|
+
OPTIONAL MATCH (g:Goal)-[:IMPLEMENTED_BY]->(ca)
|
|
690
|
+
|
|
691
|
+
// Find related tests
|
|
692
|
+
OPTIONAL MATCH (ca)-[:COVERED_BY]->(tc:TestCase)
|
|
693
|
+
|
|
694
|
+
// Find strategies via goals
|
|
695
|
+
OPTIONAL MATCH (g)-[:HAS_STRATEGY]->(s:Strategy)
|
|
696
|
+
|
|
697
|
+
WITH
|
|
698
|
+
collect(DISTINCT g {.*}) as affected_goals,
|
|
699
|
+
collect(DISTINCT tc {.*}) as tests_to_run,
|
|
700
|
+
collect(DISTINCT s {.*}) as strategies_to_review,
|
|
701
|
+
collect(DISTINCT ca {.*}) as artifacts
|
|
702
|
+
RETURN affected_goals, tests_to_run, strategies_to_review, artifacts
|
|
703
|
+
"""
|
|
704
|
+
result = await self.client.execute_query(
|
|
705
|
+
query, {"project_id": project_id, "paths": paths}
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
if result:
|
|
709
|
+
return {
|
|
710
|
+
"goals_to_retest": [g for g in result[0]["affected_goals"] if g],
|
|
711
|
+
"tests_to_run": [t for t in result[0]["tests_to_run"] if t],
|
|
712
|
+
"strategies_to_review": [s for s in result[0]["strategies_to_review"] if s],
|
|
713
|
+
"artifacts_related": [a for a in result[0]["artifacts"] if a],
|
|
714
|
+
}
|
|
715
|
+
return {
|
|
716
|
+
"goals_to_retest": [],
|
|
717
|
+
"tests_to_run": [],
|
|
718
|
+
"strategies_to_review": [],
|
|
719
|
+
"artifacts_related": [],
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async def get_goal_subgraph(
|
|
723
|
+
self, goal_id: str, k_hops: int = 2
|
|
724
|
+
) -> Dict[str, Any]:
|
|
725
|
+
"""
|
|
726
|
+
Get the subgraph around a goal up to k hops.
|
|
727
|
+
|
|
728
|
+
Returns the goal and all connected entities within k hops.
|
|
729
|
+
"""
|
|
730
|
+
query = """
|
|
731
|
+
MATCH path = (g:Goal {id: $goal_id})-[*1..$k_hops]-(connected)
|
|
732
|
+
WITH g, collect(DISTINCT connected) as connected_nodes, collect(path) as paths
|
|
733
|
+
RETURN g {.*} as goal, connected_nodes
|
|
734
|
+
"""
|
|
735
|
+
result = await self.client.execute_query(
|
|
736
|
+
query, {"goal_id": goal_id, "k_hops": k_hops}
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
if result:
|
|
740
|
+
return {
|
|
741
|
+
"goal": result[0]["goal"],
|
|
742
|
+
"connected": result[0]["connected_nodes"],
|
|
743
|
+
}
|
|
744
|
+
return {"goal": None, "connected": []}
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
# Singleton instance
|
|
748
|
+
_repository: Optional[KGRepository] = None
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def get_repository() -> KGRepository:
|
|
752
|
+
"""Get or create the repository singleton."""
|
|
753
|
+
global _repository
|
|
754
|
+
if _repository is None:
|
|
755
|
+
_repository = KGRepository()
|
|
756
|
+
return _repository
|