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/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
@@ -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
@@ -0,0 +1,4 @@
1
+ """
2
+ LLM submodule for MCP-KG-Memory.
3
+ Handles communication with Gemini via LiteLLM.
4
+ """
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