sadidft commited on
Commit
12306e8
·
verified ·
1 Parent(s): 11428b9

Create brain.py

Browse files
Files changed (1) hide show
  1. brain.py +949 -0
brain.py ADDED
@@ -0,0 +1,949 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Cogni-Engine v1 — Cognitive Engine (Brain)
3
+ The central coordinator that connects all components.
4
+ Processes user requests through three stages:
5
+ 1. UNDERSTAND — Parse intent, extract entities, build query
6
+ 2. REASON — Search graph, traverse, build reasoning chains
7
+ 3. RESPOND — Generate natural language from chains
8
+
9
+ Also manages conversation sessions and knowledge extraction.
10
+ """
11
+
12
+ import time
13
+ import threading
14
+ from typing import List, Dict, Optional, Tuple, Any
15
+
16
+ import numpy as np
17
+
18
+ import config
19
+ import utils
20
+ from knowledge import KnowledgeGraph, Node, Edge, ReasoningChain
21
+ from language import LanguageGenerator
22
+ from thinker import Thinker
23
+
24
+
25
+ # ═══════════════════════════════════════════════════════════
26
+ # CONVERSATION SESSION
27
+ # ═══════════════════════════════════════════════════════════
28
+
29
+ class Session:
30
+ """
31
+ Tracks a multi-turn conversation.
32
+ Maintains context window for coherent dialogue.
33
+ """
34
+
35
+ __slots__ = [
36
+ 'id', 'messages', 'context_entities', 'context_node_ids',
37
+ 'system_prompt', 'personality', 'last_active', 'turn_count'
38
+ ]
39
+
40
+ def __init__(self, session_id: str, system_prompt: str = ""):
41
+ self.id = session_id
42
+ self.messages: List[dict] = [] # [{role, content}]
43
+ self.context_entities: List[str] = []
44
+ self.context_node_ids: List[str] = []
45
+ self.system_prompt = system_prompt
46
+ self.personality = utils.parse_system_prompt(system_prompt)
47
+ self.last_active = time.time()
48
+ self.turn_count = 0
49
+
50
+ def add_message(self, role: str, content: str):
51
+ """Add a message to conversation history."""
52
+ self.messages.append({"role": role, "content": content})
53
+ # Trim to context window
54
+ max_messages = config.CONTEXT_WINDOW_TURNS * 2 # user + assistant pairs
55
+ if len(self.messages) > max_messages:
56
+ # Keep system prompt awareness but trim old messages
57
+ self.messages = self.messages[-max_messages:]
58
+ self.last_active = time.time()
59
+ self.turn_count += 1
60
+
61
+ def add_context_entities(self, entities: List[str]):
62
+ """Add discovered entities to session context."""
63
+ for e in entities:
64
+ if e not in self.context_entities:
65
+ self.context_entities.append(e)
66
+ # Keep last N entities
67
+ self.context_entities = self.context_entities[-30:]
68
+
69
+ def add_context_nodes(self, node_ids: List[str]):
70
+ """Add discovered node IDs to session context."""
71
+ for nid in node_ids:
72
+ if nid not in self.context_node_ids:
73
+ self.context_node_ids.append(nid)
74
+ self.context_node_ids = self.context_node_ids[-50:]
75
+
76
+ def get_context_text(self) -> str:
77
+ """Get combined context from recent messages."""
78
+ recent = self.messages[-config.CONTEXT_WINDOW_TURNS * 2:]
79
+ parts = []
80
+ for msg in recent:
81
+ if msg["role"] == "user":
82
+ parts.append(msg["content"])
83
+ return " ".join(parts)
84
+
85
+ def is_expired(self) -> bool:
86
+ """Check if session has expired."""
87
+ return (time.time() - self.last_active) > (config.SESSION_TIMEOUT_MINUTES * 60)
88
+
89
+
90
+ # ═══════════════════════════════════════════════════════════
91
+ # SESSION MANAGER
92
+ # ═══════════════════════════════════════════════════════════
93
+
94
+ class SessionManager:
95
+ """Manages active conversation sessions."""
96
+
97
+ def __init__(self):
98
+ self._sessions: Dict[str, Session] = {}
99
+ self._lock = threading.Lock()
100
+
101
+ def get_or_create(
102
+ self,
103
+ session_id: str = None,
104
+ system_prompt: str = ""
105
+ ) -> Session:
106
+ """Get existing session or create new one."""
107
+ with self._lock:
108
+ if session_id and session_id in self._sessions:
109
+ session = self._sessions[session_id]
110
+ session.last_active = time.time()
111
+ # Update system prompt if changed
112
+ if system_prompt and system_prompt != session.system_prompt:
113
+ session.system_prompt = system_prompt
114
+ session.personality = utils.parse_system_prompt(system_prompt)
115
+ return session
116
+
117
+ # Create new session
118
+ new_id = session_id or config.generate_session_id()
119
+ session = Session(new_id, system_prompt)
120
+ self._sessions[new_id] = session
121
+ return session
122
+
123
+ def remove(self, session_id: str):
124
+ """Remove a session."""
125
+ with self._lock:
126
+ self._sessions.pop(session_id, None)
127
+
128
+ def cleanup_expired(self):
129
+ """Remove expired sessions."""
130
+ with self._lock:
131
+ expired = [
132
+ sid for sid, s in self._sessions.items()
133
+ if s.is_expired()
134
+ ]
135
+ for sid in expired:
136
+ del self._sessions[sid]
137
+ if expired:
138
+ print(f"[SESSION] Cleaned up {len(expired)} expired sessions")
139
+
140
+ @property
141
+ def active_count(self) -> int:
142
+ return len(self._sessions)
143
+
144
+
145
+ # ═══════════════════════════════════════════════════════════
146
+ # BRAIN — MAIN COGNITIVE ENGINE
147
+ # ═══════════════════════════════════════════════════════════
148
+
149
+ class Brain:
150
+ """
151
+ Central cognitive engine.
152
+ Coordinates understanding, reasoning, and response generation.
153
+
154
+ Usage:
155
+ brain = Brain(graph, thinker)
156
+ response = brain.process_message(messages, session_id)
157
+ """
158
+
159
+ def __init__(self, graph: KnowledgeGraph, thinker: Thinker):
160
+ self.graph = graph
161
+ self.thinker = thinker
162
+ self.language = LanguageGenerator()
163
+ self.sessions = SessionManager()
164
+
165
+ # Processing stats
166
+ self._total_requests = 0
167
+ self._total_response_time = 0.0
168
+ self._avg_confidence = 0.0
169
+
170
+ # ───────────────────────────────────────────────────
171
+ # MAIN ENTRY POINT
172
+ # ───────────────────────────────────────────────────
173
+
174
+ def process_message(
175
+ self,
176
+ messages: List[dict],
177
+ session_id: str = None,
178
+ temperature: float = None
179
+ ) -> dict:
180
+ """
181
+ Process a chat completion request.
182
+
183
+ Args:
184
+ messages: List of {role, content} messages (OpenAI format)
185
+ session_id: Optional session ID for multi-turn
186
+ temperature: Response variation (0-1)
187
+
188
+ Returns:
189
+ {
190
+ "response": str, # The generated response text
191
+ "session_id": str, # Session ID for continuity
192
+ "confidence": float, # Response confidence
193
+ "reasoning_depth": int, # How deep the reasoning went
194
+ "nodes_traversed": int, # How many nodes were visited
195
+ "chains_used": int, # How many reasoning chains
196
+ "thinking_cycles": int, # Total thinker cycles so far
197
+ "processing_time_ms": int # How long this took
198
+ }
199
+ """
200
+ start_time = time.time()
201
+
202
+ if temperature is None:
203
+ temperature = config.DEFAULT_TEMPERATURE
204
+
205
+ # ── Extract system prompt and user message ──
206
+ system_prompt = ""
207
+ user_message = ""
208
+ conversation_history = []
209
+
210
+ for msg in messages:
211
+ role = msg.get("role", "")
212
+ content = msg.get("content", "")
213
+
214
+ if role == "system":
215
+ system_prompt = content
216
+ elif role == "user":
217
+ user_message = content
218
+ conversation_history.append(msg)
219
+ elif role == "assistant":
220
+ conversation_history.append(msg)
221
+
222
+ if not user_message:
223
+ return self._empty_response(session_id, start_time)
224
+
225
+ # ── Get or create session ──
226
+ session = self.sessions.get_or_create(session_id, system_prompt)
227
+ session.add_message("user", user_message)
228
+
229
+ # ── STAGE 1: UNDERSTAND ──
230
+ query_analysis = self._understand(
231
+ user_message, session, temperature
232
+ )
233
+
234
+ # ── STAGE 2: REASON ──
235
+ reasoning_result = self._reason(query_analysis, session)
236
+
237
+ # ── STAGE 3: RESPOND ──
238
+ response_text = self._respond(
239
+ reasoning_result, query_analysis, session
240
+ )
241
+
242
+ # ── Post-processing ──
243
+ session.add_message("assistant", response_text)
244
+
245
+ # Extract knowledge from user message (async-safe)
246
+ self._extract_user_knowledge(user_message)
247
+
248
+ # Reinforce used chains and edges
249
+ self._reinforce_used_knowledge(reasoning_result)
250
+
251
+ # Update stats
252
+ processing_time = time.time() - start_time
253
+ self._total_requests += 1
254
+ self._total_response_time += processing_time
255
+
256
+ result = {
257
+ "response": response_text,
258
+ "session_id": session.id,
259
+ "confidence": reasoning_result.get("confidence", 0.0),
260
+ "reasoning_depth": reasoning_result.get("max_depth", 0),
261
+ "nodes_traversed": reasoning_result.get("nodes_traversed", 0),
262
+ "chains_used": len(reasoning_result.get("chains", [])),
263
+ "thinking_cycles": self.thinker.total_cycles,
264
+ "processing_time_ms": int(processing_time * 1000)
265
+ }
266
+
267
+ if config.LOG_API_REQUESTS:
268
+ print(
269
+ f"[BRAIN] Request processed: "
270
+ f"confidence={result['confidence']:.2f}, "
271
+ f"depth={result['reasoning_depth']}, "
272
+ f"nodes={result['nodes_traversed']}, "
273
+ f"chains={result['chains_used']}, "
274
+ f"time={result['processing_time_ms']}ms"
275
+ )
276
+
277
+ return result
278
+
279
+ # ═══════════════════════════════════════════════════
280
+ # STAGE 1: UNDERSTAND
281
+ # ═══════════════════════════════════════════════════
282
+
283
+ def _understand(
284
+ self,
285
+ message: str,
286
+ session: Session,
287
+ temperature: float
288
+ ) -> dict:
289
+ """
290
+ Parse user message to understand what is being asked.
291
+
292
+ Returns query_analysis dict:
293
+ {
294
+ intent: str,
295
+ intent_confidence: float,
296
+ entities: [str],
297
+ keywords: [str],
298
+ query_vector: np.ndarray,
299
+ temperature: float,
300
+ is_followup: bool,
301
+ context_entities: [str],
302
+ query_text: str,
303
+ confidence: float (initial, from intent detection)
304
+ }
305
+ """
306
+
307
+ # ── Detect intent ──
308
+ intent, intent_confidence = utils.detect_intent(message)
309
+
310
+ # ── Check if this is a follow-up ──
311
+ is_followup = self._is_followup(message, session)
312
+ if is_followup and intent == "general":
313
+ intent = "followup"
314
+
315
+ # ── Extract entities ──
316
+ entities = utils.extract_entities_simple(message)
317
+
318
+ # ── Extract keywords ──
319
+ keywords = utils.extract_keywords(message, max_keywords=15)
320
+
321
+ # If no entities found, use keywords as entities
322
+ if not entities and keywords:
323
+ entities = keywords[:5]
324
+
325
+ # ── Build context-enriched query ──
326
+ query_parts = [message]
327
+
328
+ if is_followup and session.context_entities:
329
+ # Add recent context for follow-up questions
330
+ query_parts.extend(session.context_entities[-5:])
331
+
332
+ query_text = " ".join(query_parts)
333
+
334
+ # ── Compute query vector ──
335
+ query_vector = utils.text_to_vector_tfidf(query_text)
336
+
337
+ # ── If follow-up, blend with previous context vector ──
338
+ if is_followup and session.context_node_ids:
339
+ context_vectors = []
340
+ for nid in session.context_node_ids[-5:]:
341
+ node = self.graph.get_node(nid)
342
+ if node:
343
+ context_vectors.append(node.vector)
344
+ if context_vectors:
345
+ context_mean = utils.vector_mean(context_vectors)
346
+ # Blend: 70% current query + 30% context
347
+ query_vector = utils.normalize(
348
+ query_vector * 0.7 + context_mean * 0.3
349
+ )
350
+
351
+ # ── Update session context ──
352
+ session.add_context_entities(entities)
353
+
354
+ return {
355
+ "intent": intent,
356
+ "intent_confidence": intent_confidence,
357
+ "entities": entities,
358
+ "keywords": keywords,
359
+ "query_vector": query_vector,
360
+ "temperature": temperature,
361
+ "is_followup": is_followup,
362
+ "context_entities": list(session.context_entities),
363
+ "query_text": query_text,
364
+ "confidence": intent_confidence
365
+ }
366
+
367
+ def _is_followup(self, message: str, session: Session) -> bool:
368
+ """Detect if message is a follow-up to previous conversation."""
369
+ if session.turn_count == 0:
370
+ return False
371
+
372
+ message_lower = message.lower().strip()
373
+
374
+ # Short messages after conversation likely follow-ups
375
+ if len(message_lower.split()) <= 5 and session.turn_count > 0:
376
+ return True
377
+
378
+ # Pronoun references
379
+ followup_indicators = [
380
+ "itu", "tersebut", "nya", "dia", "mereka",
381
+ "lanjutkan", "jelaskan lagi", "maksudnya",
382
+ "terus", "lalu", "bagaimana dengan",
383
+ "it", "that", "they", "them", "those",
384
+ "what about", "how about", "tell me more",
385
+ "continue", "go on", "elaborate",
386
+ "dan", "juga", "selain itu",
387
+ ]
388
+
389
+ for indicator in followup_indicators:
390
+ if indicator in message_lower:
391
+ return True
392
+
393
+ return False
394
+
395
+ # ═══════════════════════════════════════════════════
396
+ # STAGE 2: REASON
397
+ # ═══════════════════��═══════════════════════════════
398
+
399
+ def _reason(self, query_analysis: dict, session: Session) -> dict:
400
+ """
401
+ Search knowledge graph and build reasoning chains.
402
+
403
+ Returns reasoning_result dict:
404
+ {
405
+ chains: [ReasoningChain],
406
+ matched_nodes: [(Node, float)],
407
+ confidence: float,
408
+ max_depth: int,
409
+ nodes_traversed: int,
410
+ direct_nodes: [Node],
411
+ direct_edges: [Edge]
412
+ }
413
+ """
414
+ query_vector = query_analysis["query_vector"]
415
+ entities = query_analysis["entities"]
416
+ intent = query_analysis["intent"]
417
+ temperature = query_analysis["temperature"]
418
+
419
+ # ── Step 1: Find matching nodes ──
420
+ matched_nodes = self._find_relevant_nodes(
421
+ query_vector, entities, session
422
+ )
423
+
424
+ if not matched_nodes:
425
+ return {
426
+ "chains": [],
427
+ "matched_nodes": [],
428
+ "confidence": 0.0,
429
+ "max_depth": 0,
430
+ "nodes_traversed": 0,
431
+ "direct_nodes": [],
432
+ "direct_edges": []
433
+ }
434
+
435
+ # Track traversed nodes
436
+ all_traversed_ids = set()
437
+
438
+ # ── Step 2: Build reasoning chains ──
439
+ start_node_ids = [node.id for node, _ in matched_nodes[:5]]
440
+ all_traversed_ids.update(start_node_ids)
441
+
442
+ chains = self.graph.build_reasoning_chains(
443
+ start_nodes=start_node_ids,
444
+ max_chains=config.MAX_CHAINS_PER_RESPONSE,
445
+ max_depth=config.MAX_TRAVERSAL_DEPTH
446
+ )
447
+
448
+ # Track all nodes in chains
449
+ for chain in chains:
450
+ for item_id in chain.path:
451
+ if item_id in self.graph.nodes:
452
+ all_traversed_ids.add(item_id)
453
+
454
+ # ── Step 3: Intent-specific reasoning ──
455
+ if intent == "relation" and len(entities) >= 2:
456
+ relation_chains = self._reason_relation(entities, temperature)
457
+ chains.extend(relation_chains)
458
+
459
+ elif intent == "compare" and len(entities) >= 2:
460
+ compare_chains = self._reason_comparison(entities, temperature)
461
+ chains.extend(compare_chains)
462
+
463
+ elif intent == "cause":
464
+ cause_chains = self._reason_causation(start_node_ids, temperature)
465
+ chains.extend(cause_chains)
466
+
467
+ # ── Step 4: Deduplicate and sort chains ──
468
+ seen_chain_ids = set()
469
+ unique_chains = []
470
+ for c in chains:
471
+ if c.id not in seen_chain_ids:
472
+ seen_chain_ids.add(c.id)
473
+ unique_chains.append(c)
474
+
475
+ unique_chains.sort(key=lambda c: c.confidence, reverse=True)
476
+ unique_chains = unique_chains[:config.MAX_CHAINS_PER_RESPONSE]
477
+
478
+ # ── Step 5: Calculate overall confidence ──
479
+ if unique_chains:
480
+ chain_confidences = [c.confidence for c in unique_chains]
481
+ max_match_sim = matched_nodes[0][1] if matched_nodes else 0.0
482
+ overall_confidence = (
483
+ max(chain_confidences) * 0.4 +
484
+ (sum(chain_confidences) / len(chain_confidences)) * 0.3 +
485
+ max_match_sim * 0.3
486
+ )
487
+ else:
488
+ overall_confidence = matched_nodes[0][1] * 0.4 if matched_nodes else 0.0
489
+
490
+ overall_confidence = utils.clamp(overall_confidence, 0.0, 1.0)
491
+
492
+ # ── Step 6: Calculate max reasoning depth ──
493
+ max_depth = 0
494
+ for chain in unique_chains:
495
+ node_count = sum(1 for i in chain.path if i in self.graph.nodes)
496
+ max_depth = max(max_depth, node_count)
497
+
498
+ # ── Collect direct nodes and edges for fallback generation ──
499
+ direct_nodes = [node for node, _ in matched_nodes[:10]]
500
+ direct_edges = []
501
+ for node in direct_nodes:
502
+ direct_edges.extend(self.graph.get_all_edges_for(node.id)[:5])
503
+
504
+ # Update session context with discovered nodes
505
+ session.add_context_nodes(list(all_traversed_ids)[:20])
506
+
507
+ return {
508
+ "chains": unique_chains,
509
+ "matched_nodes": matched_nodes,
510
+ "confidence": round(overall_confidence, 4),
511
+ "max_depth": max_depth,
512
+ "nodes_traversed": len(all_traversed_ids),
513
+ "direct_nodes": direct_nodes,
514
+ "direct_edges": direct_edges
515
+ }
516
+
517
+ def _find_relevant_nodes(
518
+ self,
519
+ query_vector: np.ndarray,
520
+ entities: List[str],
521
+ session: Session
522
+ ) -> List[Tuple[Node, float]]:
523
+ """
524
+ Find nodes relevant to the query using multiple strategies.
525
+ Combines vector similarity with entity matching.
526
+ """
527
+ all_matches: Dict[str, Tuple[Node, float]] = {}
528
+
529
+ # ── Strategy 1: Vector similarity search ──
530
+ vector_matches = self.graph.find_similar_nodes(
531
+ query_vector,
532
+ top_k=config.MAX_NODES_PER_SEARCH,
533
+ min_similarity=0.2
534
+ )
535
+ for node, sim in vector_matches:
536
+ if node.id not in all_matches or sim > all_matches[node.id][1]:
537
+ all_matches[node.id] = (node, sim)
538
+
539
+ # ── Strategy 2: Entity exact/fuzzy match ──
540
+ for entity in entities:
541
+ # Exact match
542
+ exact_node = self.graph.get_node_by_content(entity)
543
+ if exact_node:
544
+ # Boost exact matches
545
+ existing_sim = all_matches.get(exact_node.id, (None, 0))[1]
546
+ all_matches[exact_node.id] = (exact_node, max(existing_sim, 0.95))
547
+
548
+ # Fuzzy match via vector
549
+ entity_vector = utils.text_to_vector_tfidf(entity)
550
+ entity_matches = self.graph.find_similar_nodes(
551
+ entity_vector,
552
+ top_k=5,
553
+ min_similarity=0.4
554
+ )
555
+ for node, sim in entity_matches:
556
+ # Boost because it matched an entity directly
557
+ boosted_sim = min(sim * 1.2, 1.0)
558
+ if node.id not in all_matches or boosted_sim > all_matches[node.id][1]:
559
+ all_matches[node.id] = (node, boosted_sim)
560
+
561
+ # ── Strategy 3: Context-based (for follow-ups) ──
562
+ if session.context_node_ids:
563
+ for ctx_nid in session.context_node_ids[-5:]:
564
+ ctx_node = self.graph.get_node(ctx_nid)
565
+ if ctx_node:
566
+ sim = utils.cosine_similarity(query_vector, ctx_node.vector)
567
+ if sim > 0.3:
568
+ # Context nodes get moderate boost
569
+ boosted = min(sim * 1.1, 1.0)
570
+ if ctx_nid not in all_matches or boosted > all_matches[ctx_nid][1]:
571
+ all_matches[ctx_nid] = (ctx_node, boosted)
572
+
573
+ # Sort by similarity descending
574
+ results = sorted(
575
+ all_matches.values(),
576
+ key=lambda x: x[1],
577
+ reverse=True
578
+ )
579
+
580
+ return results[:config.MAX_NODES_PER_SEARCH]
581
+
582
+ def _reason_relation(
583
+ self, entities: List[str], temperature: float
584
+ ) -> List[ReasoningChain]:
585
+ """Find relationship between two entities."""
586
+ if len(entities) < 2:
587
+ return []
588
+
589
+ chains = []
590
+
591
+ # Find nodes for both entities
592
+ node_a = self.graph.get_node_by_content(entities[0])
593
+ node_b = self.graph.get_node_by_content(entities[1])
594
+
595
+ if not node_a:
596
+ matches = self.graph.find_similar_to_text(entities[0], top_k=1, min_similarity=0.4)
597
+ if matches:
598
+ node_a = matches[0][0]
599
+
600
+ if not node_b:
601
+ matches = self.graph.find_similar_to_text(entities[1], top_k=1, min_similarity=0.4)
602
+ if matches:
603
+ node_b = matches[0][0]
604
+
605
+ if not node_a or not node_b:
606
+ return []
607
+
608
+ # Find paths between them
609
+ paths = self.graph.find_paths(
610
+ node_a.id, node_b.id,
611
+ max_depth=config.MAX_TRAVERSAL_DEPTH,
612
+ max_paths=3
613
+ )
614
+
615
+ for path in paths:
616
+ confidence = self._score_path(path)
617
+ chain = ReasoningChain(
618
+ chain_id=config.generate_chain_id(path),
619
+ path=path,
620
+ conclusion=f"{entities[0]} → {entities[1]}",
621
+ confidence=confidence
622
+ )
623
+ chains.append(chain)
624
+
625
+ # Also try reverse direction
626
+ reverse_paths = self.graph.find_paths(
627
+ node_b.id, node_a.id,
628
+ max_depth=config.MAX_TRAVERSAL_DEPTH,
629
+ max_paths=2
630
+ )
631
+ for path in reverse_paths:
632
+ confidence = self._score_path(path)
633
+ chain = ReasoningChain(
634
+ chain_id=config.generate_chain_id(path),
635
+ path=path,
636
+ conclusion=f"{entities[1]} → {entities[0]}",
637
+ confidence=confidence
638
+ )
639
+ chains.append(chain)
640
+
641
+ return chains
642
+
643
+ def _reason_comparison(
644
+ self, entities: List[str], temperature: float
645
+ ) -> List[ReasoningChain]:
646
+ """Build comparison reasoning between entities."""
647
+ if len(entities) < 2:
648
+ return []
649
+
650
+ chains = []
651
+
652
+ for entity in entities[:2]:
653
+ matches = self.graph.find_similar_to_text(
654
+ entity, top_k=1, min_similarity=0.3
655
+ )
656
+ if matches:
657
+ node = matches[0][0]
658
+ entity_chains = self.graph.build_reasoning_chains(
659
+ [node.id], max_chains=2, max_depth=4
660
+ )
661
+ chains.extend(entity_chains)
662
+
663
+ return chains
664
+
665
+ def _reason_causation(
666
+ self, start_node_ids: List[str], temperature: float
667
+ ) -> List[ReasoningChain]:
668
+ """Follow causal chains from starting nodes."""
669
+ chains = []
670
+
671
+ for nid in start_node_ids[:3]:
672
+ # Follow "causes" edges specifically
673
+ current = nid
674
+ path = [current]
675
+ visited = {current}
676
+
677
+ for _ in range(config.MAX_TRAVERSAL_DEPTH):
678
+ cause_edges = [
679
+ e for e in self.graph.get_edges_from(current)
680
+ if e.relation in ("causes", "leads_to", "results_in")
681
+ and e.to_node not in visited
682
+ ]
683
+ if not cause_edges:
684
+ break
685
+
686
+ best = max(cause_edges, key=lambda e: e.confidence)
687
+ path.append(best.id)
688
+ path.append(best.to_node)
689
+ visited.add(best.to_node)
690
+ current = best.to_node
691
+
692
+ if len(path) >= 3:
693
+ confidence = self._score_path(path)
694
+ chain = ReasoningChain(
695
+ chain_id=config.generate_chain_id(path),
696
+ path=path,
697
+ conclusion="causal_chain",
698
+ confidence=confidence
699
+ )
700
+ chains.append(chain)
701
+
702
+ return chains
703
+
704
+ def _score_path(self, path: list) -> float:
705
+ """Score a path for confidence."""
706
+ edge_scores = []
707
+ for item_id in path:
708
+ edge = self.graph.get_edge(item_id)
709
+ if edge:
710
+ edge_scores.append(edge.weight * edge.confidence)
711
+
712
+ if not edge_scores:
713
+ return 0.3
714
+
715
+ avg = sum(edge_scores) / len(edge_scores)
716
+ length_penalty = 1.0 / (1.0 + 0.1 * len(edge_scores))
717
+
718
+ return utils.clamp(avg * length_penalty, 0.0, 1.0)
719
+
720
+ # ═══════════════════════════════════════════════════
721
+ # STAGE 3: RESPOND
722
+ # ═══════════════════════════════════════════════════
723
+
724
+ def _respond(
725
+ self,
726
+ reasoning_result: dict,
727
+ query_analysis: dict,
728
+ session: Session
729
+ ) -> str:
730
+ """
731
+ Generate natural language response from reasoning results.
732
+ Uses compositional language generation.
733
+ """
734
+ chains = reasoning_result.get("chains", [])
735
+ confidence = reasoning_result.get("confidence", 0.0)
736
+ direct_nodes = reasoning_result.get("direct_nodes", [])
737
+ direct_edges = reasoning_result.get("direct_edges", [])
738
+
739
+ personality = session.personality
740
+ lang = personality.get("language", config.DEFAULT_LANGUAGE)
741
+
742
+ # Merge confidence from reasoning into query analysis
743
+ query_analysis_with_confidence = dict(query_analysis)
744
+ query_analysis_with_confidence["confidence"] = confidence
745
+
746
+ graph_stats = self.graph.get_stats()
747
+
748
+ # ── Primary path: Chain-based generation ──
749
+ if chains:
750
+ response = self.language.generate_response(
751
+ chains=chains,
752
+ query_analysis=query_analysis_with_confidence,
753
+ personality=personality,
754
+ all_nodes=self.graph.nodes,
755
+ all_edges=self.graph.edges,
756
+ graph_stats=graph_stats
757
+ )
758
+ if response and response.strip():
759
+ return response
760
+
761
+ # ── Fallback: Direct node-based generation ──
762
+ if direct_nodes:
763
+ response = self.language.generate_from_direct_nodes(
764
+ nodes=direct_nodes,
765
+ edges=direct_edges,
766
+ query_analysis=query_analysis_with_confidence,
767
+ personality=personality,
768
+ all_nodes=self.graph.nodes,
769
+ lang=lang
770
+ )
771
+ if response and response.strip():
772
+ return response
773
+
774
+ # ── Last resort: Honest uncertainty ──
775
+ return self._generate_uncertainty_response(
776
+ query_analysis, personality, graph_stats, lang
777
+ )
778
+
779
+ def _generate_uncertainty_response(
780
+ self,
781
+ query_analysis: dict,
782
+ personality: dict,
783
+ graph_stats: dict,
784
+ lang: str
785
+ ) -> str:
786
+ """
787
+ Generate an honest uncertainty response.
788
+ Built compositionally — NOT a static template.
789
+ """
790
+ # Force uncertainty structure
791
+ query_analysis_low = dict(query_analysis)
792
+ query_analysis_low["confidence"] = 0.1
793
+
794
+ # Create minimal chains list (empty)
795
+ response = self.language.generate_response(
796
+ chains=[],
797
+ query_analysis=query_analysis_low,
798
+ personality=personality,
799
+ all_nodes=self.graph.nodes,
800
+ all_edges=self.graph.edges,
801
+ graph_stats=graph_stats
802
+ )
803
+
804
+ if response and response.strip():
805
+ return response
806
+
807
+ # Absolute last fallback (should rarely reach here)
808
+ entities = query_analysis.get("entities", [])
809
+ topic = ", ".join(entities[:2]) if entities else "topik tersebut"
810
+
811
+ rng = utils.seeded_random(utils.variation_seed())
812
+
813
+ if lang == "id":
814
+ options = [
815
+ f"Saya belum memiliki informasi yang cukup mengenai {topic}. "
816
+ f"Pengetahuan saya akan berkembang seiring waktu dan dengan "
817
+ f"penambahan data yang relevan.",
818
+
819
+ f"Mengenai {topic}, pemahaman saya masih terbatas saat ini. "
820
+ f"Dengan berjalannya waktu dan penambahan informasi, saya akan "
821
+ f"mampu membahas topik ini dengan lebih baik.",
822
+
823
+ f"Topik {topic} belum tercakup secara memadai dalam pengetahuan "
824
+ f"saya saat ini. Saya terus belajar dan memperluas pemahaman "
825
+ f"saya secara mandiri.",
826
+ ]
827
+ else:
828
+ options = [
829
+ f"I don't have sufficient information about {topic} yet. "
830
+ f"My knowledge grows over time and with the addition of "
831
+ f"relevant data.",
832
+
833
+ f"Regarding {topic}, my understanding is currently limited. "
834
+ f"As time goes on and more information is added, I'll be "
835
+ f"able to discuss this topic more thoroughly.",
836
+
837
+ f"The topic of {topic} isn't yet well covered in my "
838
+ f"knowledge base. I'm continuously learning and expanding "
839
+ f"my understanding autonomously.",
840
+ ]
841
+
842
+ return rng.choice(options)
843
+
844
+ # ───────────────────────────────────────────────────
845
+ # POST-PROCESSING
846
+ # ───────────────────────────────────────────────────
847
+
848
+ def _extract_user_knowledge(self, message: str):
849
+ """
850
+ Extract knowledge from user message.
851
+ Delegates to thinker for knowledge extraction.
852
+ Does NOT store raw message.
853
+ """
854
+ try:
855
+ self.thinker.extract_from_user_message(message)
856
+ except Exception as e:
857
+ if config.LOG_THINKING_DETAILS:
858
+ print(f"[BRAIN] Knowledge extraction error: {e}")
859
+
860
+ def _reinforce_used_knowledge(self, reasoning_result: dict):
861
+ """Reinforce edges and chains that were used in this response."""
862
+ chains = reasoning_result.get("chains", [])
863
+
864
+ for chain in chains:
865
+ # Save and reinforce chain
866
+ self.graph.save_chain(chain)
867
+ self.graph.reinforce_chain(chain.id)
868
+
869
+ # ───────────────────────────────────────────────────
870
+ # UTILITY RESPONSES
871
+ # ───────────────────────────────────────────────────
872
+
873
+ def _empty_response(self, session_id: str, start_time: float) -> dict:
874
+ """Return response for empty/invalid input."""
875
+ processing_time = time.time() - start_time
876
+ return {
877
+ "response": "",
878
+ "session_id": session_id or "",
879
+ "confidence": 0.0,
880
+ "reasoning_depth": 0,
881
+ "nodes_traversed": 0,
882
+ "chains_used": 0,
883
+ "thinking_cycles": self.thinker.total_cycles,
884
+ "processing_time_ms": int(processing_time * 1000)
885
+ }
886
+
887
+ # ───────────────────────────────────────────────────
888
+ # STATUS & STATS
889
+ # ───────────────────────────────────────────────────
890
+
891
+ def get_status(self) -> dict:
892
+ """Get comprehensive brain status."""
893
+ graph_stats = self.graph.get_stats()
894
+ thinker_status = self.thinker.get_status()
895
+ intelligence_score = self.graph.get_intelligence_score()
896
+
897
+ avg_response_time = (
898
+ (self._total_response_time / self._total_requests * 1000)
899
+ if self._total_requests > 0 else 0
900
+ )
901
+
902
+ return {
903
+ "alive": True,
904
+ "intelligence_score": round(intelligence_score, 2),
905
+
906
+ # Graph stats
907
+ "graph": {
908
+ "total_nodes": graph_stats["total_nodes"],
909
+ "total_edges": graph_stats["total_edges"],
910
+ "total_chains": graph_stats["total_chains"],
911
+ "inferred_nodes": graph_stats["inferred_nodes"],
912
+ "inferred_edges": graph_stats["inferred_edges"],
913
+ "max_abstraction_depth": graph_stats["max_abstraction_depth"],
914
+ "avg_connections": graph_stats["avg_connections"],
915
+ "avg_confidence": graph_stats["avg_confidence"],
916
+ "inference_ratio": graph_stats["inference_ratio"],
917
+ },
918
+
919
+ # Thinker stats
920
+ "thinker": {
921
+ "running": thinker_status["running"],
922
+ "current_phase": thinker_status["current_phase"],
923
+ "total_cycles": thinker_status["total_cycles"],
924
+ "interval_seconds": thinker_status["interval_seconds"],
925
+ "metrics": thinker_status["metrics"],
926
+ },
927
+
928
+ # API stats
929
+ "api": {
930
+ "total_requests": self._total_requests,
931
+ "avg_response_time_ms": round(avg_response_time, 1),
932
+ "active_sessions": self.sessions.active_count,
933
+ },
934
+
935
+ # Memory stats
936
+ "memory": self.graph.memory.get_db_stats(),
937
+ }
938
+
939
+ def cleanup(self):
940
+ """Periodic cleanup tasks."""
941
+ self.sessions.cleanup_expired()
942
+
943
+ def shutdown(self):
944
+ """Graceful shutdown."""
945
+ print("[BRAIN] Shutting down...")
946
+ self.thinker.stop()
947
+ self.graph.force_sync()
948
+ self.graph.memory.shutdown()
949
+ print("[BRAIN] Shutdown complete.")