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/retrieval.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Retrieval module for building context packs from the knowledge graph.
|
|
3
|
+
Navigates the graph to construct relevant context for IDE agents.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from kg_mcp.kg.repo import get_repository
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ContextBuilder:
|
|
16
|
+
"""Builds context packs from the knowledge graph."""
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self.repo = get_repository()
|
|
20
|
+
|
|
21
|
+
async def build_context_pack(
|
|
22
|
+
self,
|
|
23
|
+
project_id: str,
|
|
24
|
+
focus_goal_id: Optional[str] = None,
|
|
25
|
+
query: Optional[str] = None,
|
|
26
|
+
k_hops: int = 2,
|
|
27
|
+
user_id: str = "default_user",
|
|
28
|
+
) -> Dict[str, Any]:
|
|
29
|
+
"""
|
|
30
|
+
Build a comprehensive context pack for an IDE agent.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
project_id: Project to build context for
|
|
34
|
+
focus_goal_id: Optional specific goal to focus on
|
|
35
|
+
query: Optional search query for additional context
|
|
36
|
+
k_hops: Number of hops for graph traversal
|
|
37
|
+
user_id: User ID for preferences
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Dict with 'markdown' (formatted context) and 'entities' (raw data)
|
|
41
|
+
"""
|
|
42
|
+
logger.info(f"Building context pack for project {project_id}")
|
|
43
|
+
|
|
44
|
+
entities: Dict[str, Any] = {
|
|
45
|
+
"active_goals": [],
|
|
46
|
+
"preferences": [],
|
|
47
|
+
"constraints": [],
|
|
48
|
+
"pain_points": [],
|
|
49
|
+
"strategies": [],
|
|
50
|
+
"recent_decisions": [],
|
|
51
|
+
"code_artifacts": [],
|
|
52
|
+
"focus_goal_subgraph": None,
|
|
53
|
+
"search_results": [],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Get active goals
|
|
57
|
+
entities["active_goals"] = await self.repo.get_active_goals(project_id)
|
|
58
|
+
logger.debug(f"Found {len(entities['active_goals'])} active goals")
|
|
59
|
+
|
|
60
|
+
# Get user preferences
|
|
61
|
+
entities["preferences"] = await self.repo.get_preferences(user_id)
|
|
62
|
+
logger.debug(f"Found {len(entities['preferences'])} preferences")
|
|
63
|
+
|
|
64
|
+
# Get open pain points
|
|
65
|
+
entities["pain_points"] = await self.repo.get_open_painpoints(project_id)
|
|
66
|
+
logger.debug(f"Found {len(entities['pain_points'])} open pain points")
|
|
67
|
+
|
|
68
|
+
# If focus goal specified, get its subgraph
|
|
69
|
+
if focus_goal_id:
|
|
70
|
+
entities["focus_goal_subgraph"] = await self.repo.get_goal_subgraph(
|
|
71
|
+
focus_goal_id, k_hops
|
|
72
|
+
)
|
|
73
|
+
# Get artifacts for the focused goal
|
|
74
|
+
entities["code_artifacts"] = await self.repo.get_artifacts_for_goal(focus_goal_id)
|
|
75
|
+
|
|
76
|
+
# If query specified, do fulltext search
|
|
77
|
+
if query:
|
|
78
|
+
entities["search_results"] = await self.repo.fulltext_search(
|
|
79
|
+
project_id=project_id,
|
|
80
|
+
query=query,
|
|
81
|
+
limit=10,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Build markdown context
|
|
85
|
+
markdown = self._format_markdown(entities, project_id)
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
"markdown": markdown,
|
|
89
|
+
"entities": entities,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
def _format_markdown(self, entities: Dict[str, Any], project_id: str) -> str:
|
|
93
|
+
"""Format entities into a structured markdown document."""
|
|
94
|
+
sections = []
|
|
95
|
+
|
|
96
|
+
# Header
|
|
97
|
+
sections.append(f"# 📋 Context Pack for Project: {project_id}")
|
|
98
|
+
sections.append(f"*Generated at: {datetime.utcnow().isoformat()}*\n")
|
|
99
|
+
|
|
100
|
+
# Active Goals
|
|
101
|
+
if entities["active_goals"]:
|
|
102
|
+
sections.append("## 🎯 Active Goals\n")
|
|
103
|
+
for i, goal in enumerate(entities["active_goals"], 1):
|
|
104
|
+
priority_emoji = self._priority_emoji(goal.get("priority", 3))
|
|
105
|
+
sections.append(
|
|
106
|
+
f"### {i}. {priority_emoji} {goal.get('title', 'Untitled')}\n"
|
|
107
|
+
)
|
|
108
|
+
if goal.get("description"):
|
|
109
|
+
sections.append(f"**Description:** {goal['description']}\n")
|
|
110
|
+
sections.append(f"**Status:** {goal.get('status', 'unknown')}")
|
|
111
|
+
sections.append(f"**Priority:** {goal.get('priority', '-')}\n")
|
|
112
|
+
|
|
113
|
+
# Acceptance criteria
|
|
114
|
+
if goal.get("acceptance_criteria"):
|
|
115
|
+
sections.append("**Acceptance Criteria:**")
|
|
116
|
+
for ac in goal["acceptance_criteria"]:
|
|
117
|
+
sections.append(f"- [ ] {ac.get('criterion', ac)}")
|
|
118
|
+
sections.append("")
|
|
119
|
+
|
|
120
|
+
# Constraints
|
|
121
|
+
if goal.get("constraints"):
|
|
122
|
+
sections.append("**Constraints:**")
|
|
123
|
+
for c in goal["constraints"]:
|
|
124
|
+
severity = c.get("severity", "must")
|
|
125
|
+
sections.append(f"- [{severity}] {c.get('description', c)}")
|
|
126
|
+
sections.append("")
|
|
127
|
+
|
|
128
|
+
# Strategies
|
|
129
|
+
if goal.get("strategies"):
|
|
130
|
+
sections.append("**Strategies:**")
|
|
131
|
+
for s in goal["strategies"]:
|
|
132
|
+
sections.append(f"- **{s.get('title', 'Strategy')}**: {s.get('approach', '')}")
|
|
133
|
+
sections.append("")
|
|
134
|
+
|
|
135
|
+
# User Preferences
|
|
136
|
+
if entities["preferences"]:
|
|
137
|
+
sections.append("## ⚙️ User Preferences\n")
|
|
138
|
+
prefs_by_category: Dict[str, List[Any]] = {}
|
|
139
|
+
for pref in entities["preferences"]:
|
|
140
|
+
cat = pref.get("category", "other")
|
|
141
|
+
if cat not in prefs_by_category:
|
|
142
|
+
prefs_by_category[cat] = []
|
|
143
|
+
prefs_by_category[cat].append(pref)
|
|
144
|
+
|
|
145
|
+
for category, prefs in prefs_by_category.items():
|
|
146
|
+
sections.append(f"**{category.replace('_', ' ').title()}:**")
|
|
147
|
+
for p in prefs:
|
|
148
|
+
strength = p.get("strength", "prefer")
|
|
149
|
+
prefix = "✅" if strength == "require" else ("❌" if strength == "avoid" else "💡")
|
|
150
|
+
sections.append(f"- {prefix} {p.get('preference', p)}")
|
|
151
|
+
sections.append("")
|
|
152
|
+
|
|
153
|
+
# Pain Points
|
|
154
|
+
if entities["pain_points"]:
|
|
155
|
+
sections.append("## ⚠️ Open Pain Points\n")
|
|
156
|
+
for pp in entities["pain_points"]:
|
|
157
|
+
severity = pp.get("severity", "medium")
|
|
158
|
+
emoji = {"critical": "🔴", "high": "🟠", "medium": "🟡", "low": "🟢"}.get(
|
|
159
|
+
severity, "⚪"
|
|
160
|
+
)
|
|
161
|
+
sections.append(f"- {emoji} **[{severity}]** {pp.get('description', pp)}")
|
|
162
|
+
if pp.get("blocking_goals"):
|
|
163
|
+
sections.append(f" - Blocking: {', '.join(pp['blocking_goals'])}")
|
|
164
|
+
sections.append("")
|
|
165
|
+
|
|
166
|
+
# Code Artifacts
|
|
167
|
+
if entities["code_artifacts"]:
|
|
168
|
+
sections.append("## 📁 Relevant Code Artifacts\n")
|
|
169
|
+
for artifact in entities["code_artifacts"]:
|
|
170
|
+
path = artifact.get("path", "unknown")
|
|
171
|
+
kind = artifact.get("kind", "file")
|
|
172
|
+
sections.append(f"- **{path}** ({kind})")
|
|
173
|
+
if artifact.get("symbols"):
|
|
174
|
+
for sym in artifact["symbols"][:5]:
|
|
175
|
+
sections.append(f" - `{sym.get('fqn', sym.get('name', 'symbol'))}`")
|
|
176
|
+
sections.append("")
|
|
177
|
+
|
|
178
|
+
# Focus Goal Subgraph
|
|
179
|
+
if entities.get("focus_goal_subgraph") and entities["focus_goal_subgraph"].get("goal"):
|
|
180
|
+
fg = entities["focus_goal_subgraph"]
|
|
181
|
+
sections.append("## 🔍 Focus Goal Details\n")
|
|
182
|
+
sections.append(f"**Goal:** {fg['goal'].get('title', 'Untitled')}\n")
|
|
183
|
+
if fg.get("connected"):
|
|
184
|
+
sections.append("**Connected entities:**")
|
|
185
|
+
for node in fg["connected"][:10]:
|
|
186
|
+
if isinstance(node, dict):
|
|
187
|
+
node_type = list(node.keys())[0] if node else "Entity"
|
|
188
|
+
sections.append(f"- {node}")
|
|
189
|
+
sections.append("")
|
|
190
|
+
|
|
191
|
+
# Search Results
|
|
192
|
+
if entities["search_results"]:
|
|
193
|
+
sections.append("## 🔎 Search Results\n")
|
|
194
|
+
for result in entities["search_results"]:
|
|
195
|
+
rtype = result.get("type", "Unknown")
|
|
196
|
+
data = result.get("data", {})
|
|
197
|
+
score = result.get("score", 0)
|
|
198
|
+
title = data.get("title") or data.get("description", str(data))[:50]
|
|
199
|
+
sections.append(f"- **[{rtype}]** {title} (score: {score:.2f})")
|
|
200
|
+
sections.append("")
|
|
201
|
+
|
|
202
|
+
# Footer with instructions
|
|
203
|
+
sections.append("---")
|
|
204
|
+
sections.append(
|
|
205
|
+
"*Use this context to guide your work. "
|
|
206
|
+
"Call `kg_link_code_artifact` when creating/modifying files to keep the graph updated.*"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return "\n".join(sections)
|
|
210
|
+
|
|
211
|
+
def _priority_emoji(self, priority: int) -> str:
|
|
212
|
+
"""Convert priority number to emoji."""
|
|
213
|
+
return {1: "🔴", 2: "🟠", 3: "🟡", 4: "🟢", 5: "⚪"}.get(priority, "⚪")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# Factory function
|
|
217
|
+
_builder: Optional[ContextBuilder] = None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def get_context_builder() -> ContextBuilder:
|
|
221
|
+
"""Get or create the context builder singleton."""
|
|
222
|
+
global _builder
|
|
223
|
+
if _builder is None:
|
|
224
|
+
_builder = ContextBuilder()
|
|
225
|
+
return _builder
|
kg_mcp/kg/schema.cypher
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Neo4j Schema Definition for MCP-KG-Memory
|
|
3
|
+
// Run this script to initialize the database schema
|
|
4
|
+
// =============================================================================
|
|
5
|
+
|
|
6
|
+
// -----------------------------------------------------------------------------
|
|
7
|
+
// CONSTRAINTS (Unique keys)
|
|
8
|
+
// -----------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
// User constraints
|
|
11
|
+
CREATE CONSTRAINT user_id_unique IF NOT EXISTS
|
|
12
|
+
FOR (u:User) REQUIRE u.id IS UNIQUE;
|
|
13
|
+
|
|
14
|
+
// Project constraints
|
|
15
|
+
CREATE CONSTRAINT project_id_unique IF NOT EXISTS
|
|
16
|
+
FOR (p:Project) REQUIRE p.id IS UNIQUE;
|
|
17
|
+
|
|
18
|
+
// Interaction constraints
|
|
19
|
+
CREATE CONSTRAINT interaction_id_unique IF NOT EXISTS
|
|
20
|
+
FOR (i:Interaction) REQUIRE i.id IS UNIQUE;
|
|
21
|
+
|
|
22
|
+
// Goal constraints
|
|
23
|
+
CREATE CONSTRAINT goal_id_unique IF NOT EXISTS
|
|
24
|
+
FOR (g:Goal) REQUIRE g.id IS UNIQUE;
|
|
25
|
+
|
|
26
|
+
// Constraint (node) constraints
|
|
27
|
+
CREATE CONSTRAINT constraint_id_unique IF NOT EXISTS
|
|
28
|
+
FOR (c:Constraint) REQUIRE c.id IS UNIQUE;
|
|
29
|
+
|
|
30
|
+
// Preference constraints
|
|
31
|
+
CREATE CONSTRAINT preference_id_unique IF NOT EXISTS
|
|
32
|
+
FOR (p:Preference) REQUIRE p.id IS UNIQUE;
|
|
33
|
+
|
|
34
|
+
// PainPoint constraints
|
|
35
|
+
CREATE CONSTRAINT painpoint_id_unique IF NOT EXISTS
|
|
36
|
+
FOR (pp:PainPoint) REQUIRE pp.id IS UNIQUE;
|
|
37
|
+
|
|
38
|
+
// Strategy constraints
|
|
39
|
+
CREATE CONSTRAINT strategy_id_unique IF NOT EXISTS
|
|
40
|
+
FOR (s:Strategy) REQUIRE s.id IS UNIQUE;
|
|
41
|
+
|
|
42
|
+
// Decision constraints
|
|
43
|
+
CREATE CONSTRAINT decision_id_unique IF NOT EXISTS
|
|
44
|
+
FOR (d:Decision) REQUIRE d.id IS UNIQUE;
|
|
45
|
+
|
|
46
|
+
// CodeArtifact constraints
|
|
47
|
+
CREATE CONSTRAINT artifact_id_unique IF NOT EXISTS
|
|
48
|
+
FOR (ca:CodeArtifact) REQUIRE ca.id IS UNIQUE;
|
|
49
|
+
|
|
50
|
+
// Symbol constraints (unique by FQN within project)
|
|
51
|
+
CREATE CONSTRAINT symbol_fqn_unique IF NOT EXISTS
|
|
52
|
+
FOR (s:Symbol) REQUIRE s.fqn IS UNIQUE;
|
|
53
|
+
|
|
54
|
+
// TestCase constraints
|
|
55
|
+
CREATE CONSTRAINT testcase_id_unique IF NOT EXISTS
|
|
56
|
+
FOR (tc:TestCase) REQUIRE tc.id IS UNIQUE;
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
// -----------------------------------------------------------------------------
|
|
60
|
+
// INDEXES (Performance)
|
|
61
|
+
// -----------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
// Project lookups
|
|
64
|
+
CREATE INDEX project_name_idx IF NOT EXISTS
|
|
65
|
+
FOR (p:Project) ON (p.name);
|
|
66
|
+
|
|
67
|
+
// Goal lookups
|
|
68
|
+
CREATE INDEX goal_status_idx IF NOT EXISTS
|
|
69
|
+
FOR (g:Goal) ON (g.status);
|
|
70
|
+
|
|
71
|
+
CREATE INDEX goal_project_idx IF NOT EXISTS
|
|
72
|
+
FOR (g:Goal) ON (g.project_id);
|
|
73
|
+
|
|
74
|
+
CREATE INDEX goal_priority_idx IF NOT EXISTS
|
|
75
|
+
FOR (g:Goal) ON (g.priority);
|
|
76
|
+
|
|
77
|
+
// Interaction lookups
|
|
78
|
+
CREATE INDEX interaction_project_idx IF NOT EXISTS
|
|
79
|
+
FOR (i:Interaction) ON (i.project_id);
|
|
80
|
+
|
|
81
|
+
CREATE INDEX interaction_timestamp_idx IF NOT EXISTS
|
|
82
|
+
FOR (i:Interaction) ON (i.timestamp);
|
|
83
|
+
|
|
84
|
+
// CodeArtifact lookups
|
|
85
|
+
CREATE INDEX artifact_path_idx IF NOT EXISTS
|
|
86
|
+
FOR (ca:CodeArtifact) ON (ca.path);
|
|
87
|
+
|
|
88
|
+
CREATE INDEX artifact_project_idx IF NOT EXISTS
|
|
89
|
+
FOR (ca:CodeArtifact) ON (ca.project_id);
|
|
90
|
+
|
|
91
|
+
// Preference lookups
|
|
92
|
+
CREATE INDEX preference_user_idx IF NOT EXISTS
|
|
93
|
+
FOR (p:Preference) ON (p.user_id);
|
|
94
|
+
|
|
95
|
+
CREATE INDEX preference_category_idx IF NOT EXISTS
|
|
96
|
+
FOR (p:Preference) ON (p.category);
|
|
97
|
+
|
|
98
|
+
// PainPoint lookups
|
|
99
|
+
CREATE INDEX painpoint_project_idx IF NOT EXISTS
|
|
100
|
+
FOR (pp:PainPoint) ON (pp.project_id);
|
|
101
|
+
|
|
102
|
+
CREATE INDEX painpoint_resolved_idx IF NOT EXISTS
|
|
103
|
+
FOR (pp:PainPoint) ON (pp.resolved);
|
|
104
|
+
|
|
105
|
+
// Symbol lookups
|
|
106
|
+
CREATE INDEX symbol_name_idx IF NOT EXISTS
|
|
107
|
+
FOR (s:Symbol) ON (s.name);
|
|
108
|
+
|
|
109
|
+
CREATE INDEX symbol_artifact_idx IF NOT EXISTS
|
|
110
|
+
FOR (s:Symbol) ON (s.artifact_id);
|
|
111
|
+
|
|
112
|
+
CREATE INDEX symbol_kind_idx IF NOT EXISTS
|
|
113
|
+
FOR (s:Symbol) ON (s.kind);
|
|
114
|
+
|
|
115
|
+
// Composite index for line range queries
|
|
116
|
+
CREATE INDEX symbol_lines_idx IF NOT EXISTS
|
|
117
|
+
FOR (s:Symbol) ON (s.line_start, s.line_end);
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
// -----------------------------------------------------------------------------
|
|
121
|
+
// FULLTEXT INDEXES (Search)
|
|
122
|
+
// -----------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
// Fulltext search on Goal title and description
|
|
125
|
+
CREATE FULLTEXT INDEX goal_fulltext IF NOT EXISTS
|
|
126
|
+
FOR (g:Goal) ON EACH [g.title, g.description];
|
|
127
|
+
|
|
128
|
+
// Fulltext search on PainPoint
|
|
129
|
+
CREATE FULLTEXT INDEX painpoint_fulltext IF NOT EXISTS
|
|
130
|
+
FOR (pp:PainPoint) ON EACH [pp.description];
|
|
131
|
+
|
|
132
|
+
// Fulltext search on Strategy
|
|
133
|
+
CREATE FULLTEXT INDEX strategy_fulltext IF NOT EXISTS
|
|
134
|
+
FOR (s:Strategy) ON EACH [s.title, s.approach];
|
|
135
|
+
|
|
136
|
+
// Fulltext search on Decision
|
|
137
|
+
CREATE FULLTEXT INDEX decision_fulltext IF NOT EXISTS
|
|
138
|
+
FOR (d:Decision) ON EACH [d.title, d.decision, d.rationale];
|
|
139
|
+
|
|
140
|
+
// Fulltext search on CodeArtifact (path and symbol)
|
|
141
|
+
CREATE FULLTEXT INDEX artifact_fulltext IF NOT EXISTS
|
|
142
|
+
FOR (ca:CodeArtifact) ON EACH [ca.path];
|
|
143
|
+
|
|
144
|
+
// Fulltext search on Interaction user text
|
|
145
|
+
CREATE FULLTEXT INDEX interaction_fulltext IF NOT EXISTS
|
|
146
|
+
FOR (i:Interaction) ON EACH [i.user_text];
|
|
147
|
+
|
|
148
|
+
// Fulltext search on Symbol (name, fqn, signature)
|
|
149
|
+
CREATE FULLTEXT INDEX symbol_fulltext IF NOT EXISTS
|
|
150
|
+
FOR (s:Symbol) ON EACH [s.name, s.fqn, s.signature];
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
// -----------------------------------------------------------------------------
|
|
154
|
+
// SAMPLE RELATIONSHIP PATTERNS (for documentation)
|
|
155
|
+
// -----------------------------------------------------------------------------
|
|
156
|
+
// These are comments showing the expected relationship types:
|
|
157
|
+
//
|
|
158
|
+
// (User)-[:PREFERS]->(Preference)
|
|
159
|
+
// (User)-[:WORKS_ON]->(Project)
|
|
160
|
+
// (Project)-[:HAS_GOAL]->(Goal)
|
|
161
|
+
// (Goal)-[:DECOMPOSES_INTO]->(Goal) -- SubGoal
|
|
162
|
+
// (Goal)-[:HAS_CONSTRAINT]->(Constraint)
|
|
163
|
+
// (Goal)-[:HAS_STRATEGY]->(Strategy)
|
|
164
|
+
// (Goal)-[:BLOCKED_BY]->(PainPoint)
|
|
165
|
+
// (Goal)-[:HAS_ACCEPTANCE_CRITERIA]->(AcceptanceCriteria)
|
|
166
|
+
// (PainPoint)-[:OBSERVED_IN]->(Interaction)
|
|
167
|
+
// (Interaction)-[:IN_PROJECT]->(Project)
|
|
168
|
+
// (Interaction)-[:PRODUCED]->(Goal|Strategy|Decision|PainPoint)
|
|
169
|
+
// (Goal)-[:IMPLEMENTED_BY]->(CodeArtifact)
|
|
170
|
+
// (CodeArtifact)-[:CONTAINS]->(Symbol)
|
|
171
|
+
// (Symbol)-[:CALLS]->(Symbol)
|
|
172
|
+
// (Symbol)-[:REFERENCES]->(Symbol)
|
|
173
|
+
// (Goal)-[:VERIFIED_BY]->(TestCase)
|
|
174
|
+
// (CodeArtifact)-[:COVERED_BY]->(TestCase)
|
|
175
|
+
// (CodeArtifact)-[:TOUCHED_IN]->(Interaction)
|
|
176
|
+
// (CodeArtifact)-[:CHANGED_IN]->(CodeChange)
|
kg_mcp/llm/__init__.py
ADDED
kg_mcp/llm/client.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM Client wrapper for Gemini via LiteLLM.
|
|
3
|
+
Provides structured extraction and linking capabilities.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import litellm
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from kg_mcp.config import get_settings
|
|
14
|
+
from kg_mcp.llm.schemas import (
|
|
15
|
+
ExtractionResult,
|
|
16
|
+
LinkingResult,
|
|
17
|
+
CodeReference,
|
|
18
|
+
GoalExtract,
|
|
19
|
+
ConstraintExtract,
|
|
20
|
+
PreferenceExtract,
|
|
21
|
+
PainPointExtract,
|
|
22
|
+
StrategyExtract,
|
|
23
|
+
AcceptanceCriteriaExtract,
|
|
24
|
+
MergeSuggestion,
|
|
25
|
+
RelationshipSuggestion,
|
|
26
|
+
)
|
|
27
|
+
from kg_mcp.llm.prompts.extractor import get_extractor_prompt
|
|
28
|
+
from kg_mcp.llm.prompts.linker import get_linker_prompt
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LLMClient:
|
|
34
|
+
"""Client for LLM operations using LiteLLM."""
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self.settings = get_settings()
|
|
38
|
+
|
|
39
|
+
# Determine active mode and primary provider
|
|
40
|
+
mode = self.settings.llm_mode
|
|
41
|
+
primary = self.settings.llm_primary
|
|
42
|
+
|
|
43
|
+
if mode == "gemini_direct" or (mode == "both" and primary == "gemini_direct"):
|
|
44
|
+
self._configure_gemini_direct()
|
|
45
|
+
elif mode == "litellm" or (mode == "both" and primary == "litellm"):
|
|
46
|
+
self._configure_litellm()
|
|
47
|
+
else:
|
|
48
|
+
# Fallback (legacy behavior)
|
|
49
|
+
if self.settings.litellm_base_url and self.settings.litellm_api_key:
|
|
50
|
+
self._configure_litellm()
|
|
51
|
+
elif self.settings.gemini_api_key:
|
|
52
|
+
self._configure_gemini_direct()
|
|
53
|
+
else:
|
|
54
|
+
self.api_base = None
|
|
55
|
+
self.api_key = None
|
|
56
|
+
self.model = self.settings.llm_model # fallback
|
|
57
|
+
logger.warning("No LLM API credentials configured!")
|
|
58
|
+
|
|
59
|
+
def _configure_gemini_direct(self):
|
|
60
|
+
"""Configure for Gemini Direct API."""
|
|
61
|
+
self.provider = "gemini"
|
|
62
|
+
self.api_base = None
|
|
63
|
+
self.api_key = self.settings.gemini_api_key
|
|
64
|
+
# For Gemini Direct via LiteLLM library, we might need to set the environment variable
|
|
65
|
+
# or pass it explicitly. LiteLLM supports 'gemini/' prefix with GEMINI_API_KEY env.
|
|
66
|
+
litellm.api_key = self.settings.gemini_api_key
|
|
67
|
+
|
|
68
|
+
# Use specific gemini model if set, else fallback
|
|
69
|
+
self.model = self.settings.gemini_model or self.settings.llm_model
|
|
70
|
+
|
|
71
|
+
# If model doesn't start with gemini/, prepend it for LiteLLM
|
|
72
|
+
if not self.model.startswith("gemini/") and "gemini" in self.model:
|
|
73
|
+
self.model = f"gemini/{self.model}"
|
|
74
|
+
|
|
75
|
+
logger.info(f"Using Direct Gemini API with model {self.model}")
|
|
76
|
+
|
|
77
|
+
def _configure_litellm(self):
|
|
78
|
+
"""Configure for LiteLLM Gateway."""
|
|
79
|
+
self.provider = "litellm"
|
|
80
|
+
self.api_base = self.settings.litellm_base_url.rstrip("/")
|
|
81
|
+
self.api_key = self.settings.litellm_api_key
|
|
82
|
+
|
|
83
|
+
# Use specific litellm model if set, else fallback
|
|
84
|
+
self.model = self.settings.litellm_model or self.settings.llm_model
|
|
85
|
+
logger.info(f"Using LiteLLM Gateway at {self.api_base} with model {self.model}")
|
|
86
|
+
|
|
87
|
+
async def extract_entities(
|
|
88
|
+
self,
|
|
89
|
+
user_text: str,
|
|
90
|
+
files: Optional[List[str]] = None,
|
|
91
|
+
diff: Optional[str] = None,
|
|
92
|
+
symbols: Optional[List[str]] = None,
|
|
93
|
+
context: Optional[str] = None,
|
|
94
|
+
) -> ExtractionResult:
|
|
95
|
+
"""
|
|
96
|
+
Extract structured entities from user text using LLM.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
user_text: The user's message/request
|
|
100
|
+
files: Optional list of file paths involved
|
|
101
|
+
diff: Optional code diff
|
|
102
|
+
symbols: Optional list of code symbols
|
|
103
|
+
context: Optional additional context
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
ExtractionResult with extracted entities
|
|
107
|
+
"""
|
|
108
|
+
logger.info(f"Extracting entities from user text: {user_text[:100]}...")
|
|
109
|
+
|
|
110
|
+
# Build the prompt
|
|
111
|
+
system_prompt, user_prompt = get_extractor_prompt(
|
|
112
|
+
user_text=user_text,
|
|
113
|
+
files=files,
|
|
114
|
+
diff=diff,
|
|
115
|
+
symbols=symbols,
|
|
116
|
+
context=context,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
# Build kwargs for litellm
|
|
121
|
+
llm_kwargs = {
|
|
122
|
+
"model": self.model,
|
|
123
|
+
"messages": [
|
|
124
|
+
{"role": "system", "content": system_prompt},
|
|
125
|
+
{"role": "user", "content": user_prompt},
|
|
126
|
+
],
|
|
127
|
+
"temperature": self.settings.llm_temperature,
|
|
128
|
+
"max_tokens": self.settings.llm_max_tokens,
|
|
129
|
+
"response_format": {"type": "json_object"},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# Add gateway config if using LiteLLM Gateway
|
|
133
|
+
if self.api_base:
|
|
134
|
+
llm_kwargs["api_base"] = self.api_base
|
|
135
|
+
llm_kwargs["api_key"] = self.api_key
|
|
136
|
+
|
|
137
|
+
response = await litellm.acompletion(**llm_kwargs)
|
|
138
|
+
|
|
139
|
+
content = response.choices[0].message.content
|
|
140
|
+
if not content:
|
|
141
|
+
logger.warning("Empty response from LLM")
|
|
142
|
+
return ExtractionResult()
|
|
143
|
+
|
|
144
|
+
# Parse JSON response
|
|
145
|
+
data = json.loads(content)
|
|
146
|
+
logger.debug(f"Extracted data: {json.dumps(data, indent=2)}")
|
|
147
|
+
|
|
148
|
+
# Validate and convert to pydantic models
|
|
149
|
+
return self._parse_extraction_result(data)
|
|
150
|
+
|
|
151
|
+
except json.JSONDecodeError as e:
|
|
152
|
+
logger.error(f"Failed to parse LLM response as JSON: {e}")
|
|
153
|
+
return ExtractionResult(confidence=0.0)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
logger.error(f"LLM extraction failed: {e}")
|
|
156
|
+
raise
|
|
157
|
+
|
|
158
|
+
async def link_entities(
|
|
159
|
+
self,
|
|
160
|
+
extraction: ExtractionResult,
|
|
161
|
+
existing_goals: List[Dict[str, Any]],
|
|
162
|
+
existing_preferences: List[Dict[str, Any]],
|
|
163
|
+
recent_interactions: List[Dict[str, Any]],
|
|
164
|
+
) -> LinkingResult:
|
|
165
|
+
"""
|
|
166
|
+
Analyze extraction results and suggest links/merges with existing entities.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
extraction: The extraction result to link
|
|
170
|
+
existing_goals: List of existing goals in the graph
|
|
171
|
+
existing_preferences: List of existing preferences
|
|
172
|
+
recent_interactions: Recent interactions for context
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
LinkingResult with merge and relationship suggestions
|
|
176
|
+
"""
|
|
177
|
+
logger.info("Linking extracted entities with existing graph...")
|
|
178
|
+
|
|
179
|
+
system_prompt, user_prompt = get_linker_prompt(
|
|
180
|
+
extraction=extraction,
|
|
181
|
+
existing_goals=existing_goals,
|
|
182
|
+
existing_preferences=existing_preferences,
|
|
183
|
+
recent_interactions=recent_interactions,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
# Build kwargs for litellm
|
|
188
|
+
llm_kwargs = {
|
|
189
|
+
"model": self.model,
|
|
190
|
+
"messages": [
|
|
191
|
+
{"role": "system", "content": system_prompt},
|
|
192
|
+
{"role": "user", "content": user_prompt},
|
|
193
|
+
],
|
|
194
|
+
"temperature": 0.1, # Lower temperature for more deterministic linking
|
|
195
|
+
"max_tokens": 2048,
|
|
196
|
+
"response_format": {"type": "json_object"},
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# Add gateway config if using LiteLLM Gateway
|
|
200
|
+
if self.api_base:
|
|
201
|
+
llm_kwargs["api_base"] = self.api_base
|
|
202
|
+
llm_kwargs["api_key"] = self.api_key
|
|
203
|
+
|
|
204
|
+
response = await litellm.acompletion(**llm_kwargs)
|
|
205
|
+
|
|
206
|
+
content = response.choices[0].message.content
|
|
207
|
+
if not content:
|
|
208
|
+
logger.warning("Empty response from LLM for linking")
|
|
209
|
+
return LinkingResult()
|
|
210
|
+
|
|
211
|
+
data = json.loads(content)
|
|
212
|
+
return self._parse_linking_result(data)
|
|
213
|
+
|
|
214
|
+
except json.JSONDecodeError as e:
|
|
215
|
+
logger.error(f"Failed to parse linking response as JSON: {e}")
|
|
216
|
+
return LinkingResult()
|
|
217
|
+
except Exception as e:
|
|
218
|
+
logger.error(f"LLM linking failed: {e}")
|
|
219
|
+
raise
|
|
220
|
+
|
|
221
|
+
def _parse_extraction_result(self, data: Dict[str, Any]) -> ExtractionResult:
|
|
222
|
+
"""Parse raw JSON into ExtractionResult."""
|
|
223
|
+
try:
|
|
224
|
+
goals = [
|
|
225
|
+
GoalExtract(**g) for g in data.get("goals", [])
|
|
226
|
+
]
|
|
227
|
+
constraints = [
|
|
228
|
+
ConstraintExtract(**c) for c in data.get("constraints", [])
|
|
229
|
+
]
|
|
230
|
+
preferences = [
|
|
231
|
+
PreferenceExtract(**p) for p in data.get("preferences", [])
|
|
232
|
+
]
|
|
233
|
+
pain_points = [
|
|
234
|
+
PainPointExtract(**pp) for pp in data.get("pain_points", [])
|
|
235
|
+
]
|
|
236
|
+
strategies = [
|
|
237
|
+
StrategyExtract(**s) for s in data.get("strategies", [])
|
|
238
|
+
]
|
|
239
|
+
acceptance_criteria = [
|
|
240
|
+
AcceptanceCriteriaExtract(**ac) for ac in data.get("acceptance_criteria", [])
|
|
241
|
+
]
|
|
242
|
+
code_references = [
|
|
243
|
+
CodeReference(**cr) for cr in data.get("code_references", [])
|
|
244
|
+
]
|
|
245
|
+
next_actions = data.get("next_actions", [])
|
|
246
|
+
confidence = data.get("confidence", 0.8)
|
|
247
|
+
|
|
248
|
+
return ExtractionResult(
|
|
249
|
+
goals=goals,
|
|
250
|
+
constraints=constraints,
|
|
251
|
+
preferences=preferences,
|
|
252
|
+
pain_points=pain_points,
|
|
253
|
+
strategies=strategies,
|
|
254
|
+
acceptance_criteria=acceptance_criteria,
|
|
255
|
+
code_references=code_references,
|
|
256
|
+
next_actions=next_actions,
|
|
257
|
+
confidence=confidence,
|
|
258
|
+
)
|
|
259
|
+
except ValidationError as e:
|
|
260
|
+
logger.warning(f"Validation error parsing extraction: {e}")
|
|
261
|
+
return ExtractionResult(confidence=0.5)
|
|
262
|
+
|
|
263
|
+
def _parse_linking_result(self, data: Dict[str, Any]) -> LinkingResult:
|
|
264
|
+
"""Parse raw JSON into LinkingResult."""
|
|
265
|
+
try:
|
|
266
|
+
merge_suggestions = [
|
|
267
|
+
MergeSuggestion(**ms) for ms in data.get("merge_suggestions", [])
|
|
268
|
+
]
|
|
269
|
+
relationships = [
|
|
270
|
+
RelationshipSuggestion(**r) for r in data.get("relationships", [])
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
return LinkingResult(
|
|
274
|
+
merge_suggestions=merge_suggestions,
|
|
275
|
+
relationships=relationships,
|
|
276
|
+
)
|
|
277
|
+
except ValidationError as e:
|
|
278
|
+
logger.warning(f"Validation error parsing linking: {e}")
|
|
279
|
+
return LinkingResult()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# Singleton instance
|
|
283
|
+
_llm_client: Optional[LLMClient] = None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def get_llm_client() -> LLMClient:
|
|
287
|
+
"""Get or create the LLM client singleton."""
|
|
288
|
+
global _llm_client
|
|
289
|
+
if _llm_client is None:
|
|
290
|
+
_llm_client = LLMClient()
|
|
291
|
+
return _llm_client
|