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/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