Komalpreet Kaur commited on
Commit
55f6497
·
unverified ·
1 Parent(s): 4f1b7b5

feat: enhance knowledge graph visualization and add new features

Browse files

- Updated package.json and package-lock.json to include "three-spritetext" for improved text rendering in the 3D graph.
- Modified KnowledgeGraph component to integrate SpriteText for always-visible node labels and relationship tags.
- Introduced new scripts for benchmarking memory retrieval latency and debugging direct interactions with the knowledge extraction system.
- Added tests for belief revision, context extraction, plural reconciliation, and semantic aliasing to ensure robust knowledge handling.
- Implemented a script to strip co-author information from commit messages.

app/api/endpoints.py CHANGED
@@ -454,7 +454,16 @@ async def process_query_stream(request: QueryRequest, current_user: str = Depend
454
  "phase": "graph",
455
  "message": "Extracting relationships for semantic memory."
456
  })
457
- triples = await asyncio.to_thread(extract_and_store_knowledge, request.text, current_user)
 
 
 
 
 
 
 
 
 
458
  yield sse_event("brain_trace", build_brain_event(
459
  "graph",
460
  71,
 
454
  "phase": "graph",
455
  "message": "Extracting relationships for semantic memory."
456
  })
457
+ # Short replies ("umm tomato") only carry meaning
458
+ # alongside the question Soma asked, so pass the
459
+ # last assistant message as extraction context.
460
+ last_soma_msg = next(
461
+ (m["content"] for m in reversed(history) if m["role"] == "assistant"),
462
+ ""
463
+ )
464
+ triples = await asyncio.to_thread(
465
+ extract_and_store_knowledge, request.text, current_user, last_soma_msg
466
+ )
467
  yield sse_event("brain_trace", build_brain_event(
468
  "graph",
469
  71,
app/db/session.py CHANGED
@@ -201,12 +201,12 @@ def get_recent_messages(session_id: str, exchanges: int = 5):
201
  cur = _cursor(conn)
202
  if _db_backend == "postgres":
203
  cur.execute(
204
- 'SELECT role, content FROM messages WHERE session_id = %s ORDER BY timestamp DESC LIMIT %s',
205
  (session_id, exchanges * 2)
206
  )
207
  else:
208
  cur.execute(
209
- 'SELECT role, content FROM messages WHERE session_id = ? ORDER BY timestamp DESC LIMIT ?',
210
  (session_id, exchanges * 2)
211
  )
212
  rows = cur.fetchall()
@@ -248,14 +248,14 @@ def prune_old_messages(session_id: str, keep_recent: int = 10):
248
  cur.execute('''
249
  DELETE FROM messages WHERE session_id = %s AND id NOT IN (
250
  SELECT id FROM messages WHERE session_id = %s
251
- ORDER BY timestamp DESC LIMIT %s
252
  )
253
  ''', (session_id, session_id, keep_recent))
254
  else:
255
  cur.execute('''
256
  DELETE FROM messages WHERE session_id = ? AND id NOT IN (
257
  SELECT id FROM messages WHERE session_id = ?
258
- ORDER BY timestamp DESC LIMIT ?
259
  )
260
  ''', (session_id, session_id, keep_recent))
261
  return get_message_count(session_id)
@@ -295,12 +295,12 @@ def get_recent_sparks(user_id: str = "default_user", limit: int = 5):
295
  cur = _cursor(conn)
296
  if _db_backend == "postgres":
297
  cur.execute(
298
- 'SELECT content, entities, timestamp FROM neural_sparks WHERE user_id = %s ORDER BY timestamp DESC LIMIT %s',
299
  (user_id, limit)
300
  )
301
  else:
302
  cur.execute(
303
- 'SELECT content, entities, timestamp FROM neural_sparks WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?',
304
  (user_id, limit)
305
  )
306
  rows = cur.fetchall()
 
201
  cur = _cursor(conn)
202
  if _db_backend == "postgres":
203
  cur.execute(
204
+ 'SELECT role, content FROM messages WHERE session_id = %s ORDER BY timestamp DESC, id DESC LIMIT %s',
205
  (session_id, exchanges * 2)
206
  )
207
  else:
208
  cur.execute(
209
+ 'SELECT role, content FROM messages WHERE session_id = ? ORDER BY timestamp DESC, id DESC LIMIT ?',
210
  (session_id, exchanges * 2)
211
  )
212
  rows = cur.fetchall()
 
248
  cur.execute('''
249
  DELETE FROM messages WHERE session_id = %s AND id NOT IN (
250
  SELECT id FROM messages WHERE session_id = %s
251
+ ORDER BY timestamp DESC, id DESC LIMIT %s
252
  )
253
  ''', (session_id, session_id, keep_recent))
254
  else:
255
  cur.execute('''
256
  DELETE FROM messages WHERE session_id = ? AND id NOT IN (
257
  SELECT id FROM messages WHERE session_id = ?
258
+ ORDER BY timestamp DESC, id DESC LIMIT ?
259
  )
260
  ''', (session_id, session_id, keep_recent))
261
  return get_message_count(session_id)
 
295
  cur = _cursor(conn)
296
  if _db_backend == "postgres":
297
  cur.execute(
298
+ 'SELECT content, entities, timestamp FROM neural_sparks WHERE user_id = %s ORDER BY timestamp DESC, id DESC LIMIT %s',
299
  (user_id, limit)
300
  )
301
  else:
302
  cur.execute(
303
+ 'SELECT content, entities, timestamp FROM neural_sparks WHERE user_id = ? ORDER BY timestamp DESC, id DESC LIMIT ?',
304
  (user_id, limit)
305
  )
306
  rows = cur.fetchall()
app/services/neocortex.py CHANGED
@@ -7,6 +7,29 @@ from langchain_core.messages import HumanMessage
7
  from app.core.config import settings
8
  from app.db.neo4j_driver import neo4j_db
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  # ── Blocked meta-nodes that should never become graph entities ──
11
  BLOCKED_NODES = {
12
  "USER", "SOMA", "AI", "ASSISTANT", "BOT", "HUMAN", "SYSTEM",
@@ -74,6 +97,23 @@ def _is_valid_node(name: str) -> bool:
74
  return True
75
 
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  def _sanitize_relation(rel: str) -> str:
78
  """Clean a relation name for Neo4j compatibility."""
79
  rel = rel.upper().strip()
@@ -82,29 +122,250 @@ def _sanitize_relation(rel: str) -> str:
82
  return rel or "RELATED_TO"
83
 
84
 
85
- def extract_and_store_knowledge(text: str, user_id: str = "default_user"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  """
87
  Child-brain knowledge extraction with 100% structurally guaranteed JSON output.
88
-
89
  Reads a conversation and extracts simple, clean concept associations —
90
  the way a child's brain naturally builds connections between ideas.
 
 
 
 
91
  """
92
  if not neo4j_db.driver:
93
  print("Knowledge Graph disabled (No DB connection).")
94
  return 0
95
-
96
- api_key = settings.GROQ_API_KEY if settings.GROQ_API_KEY else "dummy_key"
97
- llm = ChatGroq(model="llama-3.1-8b-instant", api_key=api_key)
98
-
99
  clean = _clean_text(text)
 
100
 
101
- # Need at least 3 words to extract a relationship (e.g., "I like apples")
102
- if len(clean.split()) < 3:
 
 
103
  print(f"Neocortex: Input too short ({len(clean.split())} words), skipping.")
104
  return 0
105
 
106
  owner = user_id.upper()
107
 
 
 
 
 
 
 
 
 
108
  prompt = f"""You are a child's brain learning about the world. Read the text and pick out SIMPLE facts as connections between concepts.
109
 
110
  Think like a child drawing a mind-map:
@@ -113,14 +374,14 @@ Think like a child drawing a mind-map:
113
  - Focus on: people, places, things, hobbies, foods, animals, feelings, skills, jobs
114
 
115
  RULES:
116
- 1. Nodes must be 1-3 word concept names, ALL CAPS. Example: "CRICKET", "DELHI", "MOM", "CODING"
117
  2. Relations must be simple verbs: LIKES, IS_A, LIVES_IN, PLAYS, WORKS_AT, HAS, KNOWS, STUDIES, etc.
118
  3. "I" or "my" in the text refers to "{owner}" — always use "{owner}" as the node name for the speaker.
119
  4. DO NOT create nodes named "USER", "SOMA", "AI", "ASSISTANT", or any chat/bot terms.
120
- 5. If the text is just greetings or small talk with zero factual content, return an empty triples list.
121
 
122
  Text:
123
- {clean}
124
 
125
  Return the extracted facts ONLY as a valid JSON block in this exact format:
126
  {{
@@ -131,49 +392,87 @@ Return the extracted facts ONLY as a valid JSON block in this exact format:
131
  Do not write any other explanation or thoughts outside the JSON block. If there are no facts, return: {{"triples": []}}"""
132
 
133
  try:
134
- response = llm.invoke([HumanMessage(content=prompt)])
135
- content = response.content.strip()
136
-
137
- # Robustly extract the JSON block
138
- triples_data = []
139
- match = re.search(r'\{.*\}', content, re.DOTALL)
140
- if match:
141
- json_str = match.group(0)
142
- try:
143
- data = json.loads(json_str)
144
- triples_data = data.get("triples", [])
145
- except Exception as e:
146
- print(f"Neocortex: Failed to parse JSON block: {e}")
147
- return 0
148
- else:
149
  print("Neocortex: No JSON block found in LLM response.")
150
  return 0
 
151
 
152
  if not triples_data:
153
  print("Neocortex: No triples extracted.")
154
  return 0
155
 
156
- stored_count = 0
157
-
 
158
  for t in triples_data:
159
  subj = str(t.get("subject", "")).strip().upper()
160
  rel = _sanitize_relation(str(t.get("relation", "")))
161
  obj = str(t.get("object", "")).strip().upper()
162
-
163
- # Validate both nodes
164
  if not _is_valid_node(subj) or not _is_valid_node(obj):
165
  continue
166
  if subj == obj: # Self-loops are meaningless
167
  continue
168
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  cypher = f"""
170
  MERGE (s:Entity {{name: $subject, user_id: $user_id}})
171
  MERGE (o:Entity {{name: $object, user_id: $user_id}})
172
- MERGE (s)-[r:`{rel}`]->(o)
173
  """
174
- neo4j_db.query(cypher, {"subject": subj, "object": obj, "user_id": user_id})
175
  stored_count += 1
176
-
 
 
 
 
 
 
 
 
177
  return stored_count
178
  except Exception as e:
179
  print(f"Neocortex extraction error: {e}")
 
7
  from app.core.config import settings
8
  from app.db.neo4j_driver import neo4j_db
9
 
10
+ # ── Models ──
11
+ # Extraction runs on every message, so it gets the fast small model.
12
+ # Judgment calls (same-entity matching, contradiction detection) need
13
+ # precision — the 8B model over-merges and over-deletes — so they get the
14
+ # bigger free-tier model, with automatic fallback if it's unavailable.
15
+ FAST_MODEL = "llama-3.1-8b-instant"
16
+ JUDGE_MODEL = "llama-3.3-70b-versatile"
17
+
18
+
19
+ def _llm_complete(prompt: str, model: str) -> str:
20
+ api_key = settings.GROQ_API_KEY if settings.GROQ_API_KEY else "dummy_key"
21
+ llm = ChatGroq(model=model, api_key=api_key)
22
+ return llm.invoke([HumanMessage(content=prompt)]).content
23
+
24
+
25
+ def _judge_complete(prompt: str) -> str:
26
+ try:
27
+ return _llm_complete(prompt, JUDGE_MODEL)
28
+ except Exception as e:
29
+ print(f"Neocortex: judge model unavailable ({e}), falling back to {FAST_MODEL}.")
30
+ return _llm_complete(prompt, FAST_MODEL)
31
+
32
+
33
  # ── Blocked meta-nodes that should never become graph entities ──
34
  BLOCKED_NODES = {
35
  "USER", "SOMA", "AI", "ASSISTANT", "BOT", "HUMAN", "SYSTEM",
 
97
  return True
98
 
99
 
100
+ def _extract_json(content: str, required_key: str) -> dict:
101
+ """
102
+ Pull the first valid JSON object containing `required_key` out of an LLM
103
+ response. Small models often wrap JSON in prose or emit several blocks,
104
+ so a bare regex-then-parse is not reliable.
105
+ """
106
+ decoder = json.JSONDecoder()
107
+ for m in re.finditer(r'\{', content):
108
+ try:
109
+ obj, _ = decoder.raw_decode(content[m.start():])
110
+ except Exception:
111
+ continue
112
+ if isinstance(obj, dict) and required_key in obj:
113
+ return obj
114
+ return {}
115
+
116
+
117
  def _sanitize_relation(rel: str) -> str:
118
  """Clean a relation name for Neo4j compatibility."""
119
  rel = rel.upper().strip()
 
122
  return rel or "RELATED_TO"
123
 
124
 
125
+ # ── Belief reconciliation ──
126
+ # New facts are never written blindly: they are first compared against what
127
+ # the graph already believes, so contradictions replace old beliefs instead
128
+ # of piling up next to them.
129
+
130
+ def _normalize_name(name: str) -> str:
131
+ """
132
+ Spelling-insensitive matching form: 'READING_BOOKS' and 'READING BOOKS'
133
+ match, and so do singular/plural variants like 'LILY' and 'LILIES'.
134
+ Only used for comparing names — never stored.
135
+ """
136
+ n = re.sub(r'[^A-Z0-9]', '', name.upper())
137
+ if n.endswith('IES') and len(n) > 4:
138
+ n = n[:-3] + 'Y'
139
+ elif n.endswith('S') and not n.endswith('SS') and len(n) > 3:
140
+ n = n[:-1]
141
+ return n
142
+
143
+
144
+ def _canonicalize_nodes(triples: list, user_id: str) -> list:
145
+ """
146
+ Entity resolution: if a new node name is just a different spelling of an
147
+ entity already in the graph, reuse the existing spelling so duplicates
148
+ like READING_BOOKS next to READING BOOKS are never created.
149
+ """
150
+ rows = neo4j_db.query(
151
+ "MATCH (n:Entity) WHERE n.user_id = $u RETURN n.name AS name LIMIT 300",
152
+ {"u": user_id},
153
+ ) or []
154
+ by_norm = {}
155
+ for r in rows:
156
+ by_norm.setdefault(_normalize_name(r["name"]), r["name"])
157
+ for t in triples:
158
+ t["subject"] = by_norm.get(_normalize_name(t["subject"]), t["subject"])
159
+ t["object"] = by_norm.get(_normalize_name(t["object"]), t["object"])
160
+ return triples
161
+
162
+
163
+ def _verify_alias(name_a: str, name_b: str) -> bool:
164
+ """
165
+ Yes/no double-check before merging two entity names. The matcher can
166
+ over-merge, and a wrong merge corrupts two facts at once — so every
167
+ match must survive this gate. Denies on any error.
168
+ """
169
+ q = (
170
+ f'Answer with exactly one word, YES or NO. Are "{name_a}" and "{name_b}" '
171
+ f'two names for the exact same thing? (Two different foods, cities, '
172
+ f'sports, people or hobbies are NO, even if similar.)'
173
+ )
174
+ try:
175
+ return _judge_complete(q).strip().upper().startswith("YES")
176
+ except Exception:
177
+ return False
178
+
179
+
180
+ def _can_coexist(new_fact: dict, edge: dict) -> bool:
181
+ """
182
+ Final safety gate before deleting a memory. Deleting is destructive,
183
+ so the judge must explicitly confirm the two facts cannot both be true.
184
+ Any doubt or error keeps the old belief.
185
+ """
186
+ q = (
187
+ f'Fact A: "{edge["s"]} {edge["rel"]} {edge["o"]}". '
188
+ f'Fact B: "{new_fact["subject"]} {new_fact["relation"]} {new_fact["object"]}". '
189
+ f'Can both facts be true at the same time? Answer with exactly one word: YES or NO. '
190
+ f'(Opposite feelings about the same thing, or two current home cities, cannot both be true. '
191
+ f'Facts about different topics can.)'
192
+ )
193
+ try:
194
+ return _judge_complete(q).strip().upper().startswith("YES")
195
+ except Exception:
196
+ return True
197
+
198
+
199
+ def _semantic_canonicalize(triples: list, user_id: str) -> list:
200
+ """
201
+ Meaning-based entity resolution: map a new node name onto an existing
202
+ entity when both refer to the SAME real-world thing in different words
203
+ (FOOTBALL -> SOCCER, MOM -> MOTHER, NYC -> NEW YORK).
204
+
205
+ An LLM does the matching instead of string or embedding similarity,
206
+ because similarity confuses "related" with "same" — DELHI and MUMBAI
207
+ are highly similar but must never merge. Falls back to no mapping on
208
+ any failure, which just means a new node is created (old behavior).
209
+ """
210
+ rows = neo4j_db.query(
211
+ "MATCH (n:Entity) WHERE n.user_id = $u RETURN n.name AS name LIMIT 150",
212
+ {"u": user_id},
213
+ ) or []
214
+ existing = [r["name"] for r in rows]
215
+ if not existing:
216
+ return triples
217
+
218
+ existing_set = set(existing)
219
+ new_names = sorted({
220
+ name for t in triples for name in (t["subject"], t["object"])
221
+ if name not in existing_set
222
+ })
223
+ if not new_names:
224
+ return triples
225
+
226
+ prompt = f"""You maintain a person's memory graph. These entities already exist in it:
227
+ {", ".join(existing)}
228
+
229
+ These NEW entity names just appeared:
230
+ {", ".join(new_names)}
231
+
232
+ For each NEW name, check if it means THE SAME real-world thing as ONE existing entity — a synonym, alias, nickname, abbreviation or translation. Examples: FOOTBALL = SOCCER, MOM = MOTHER, NYC = NEW YORK.
233
+
234
+ STRICT RULE: related is NOT the same. Different cities (DELHI vs MUMBAI), different foods (PIZZA vs PASTA), different sports (CRICKET vs BADMINTON) are NOT matches — two names merely in the same category are different things. When unsure, do NOT match.
235
+
236
+ Return ONLY JSON: {{"matches": {{"NEW_NAME": "EXISTING_NAME"}}}}. If nothing matches, return {{"matches": {{}}}}."""
237
+
238
+ try:
239
+ data = _extract_json(_judge_complete(prompt), "matches")
240
+ raw = data.get("matches", {}) or {}
241
+
242
+ mapping = {}
243
+ for new_name, old_name in raw.items():
244
+ new_name, old_name = str(new_name).upper(), str(old_name).upper()
245
+ # Only trust mappings between names we actually presented,
246
+ # and only after they survive the yes/no verification gate.
247
+ if new_name in new_names and old_name in existing_set and _verify_alias(new_name, old_name):
248
+ mapping[new_name] = old_name
249
+
250
+ if mapping:
251
+ print(f"Neocortex: semantic aliases resolved: {mapping}")
252
+ for t in triples:
253
+ t["subject"] = mapping.get(t["subject"], t["subject"])
254
+ t["object"] = mapping.get(t["object"], t["object"])
255
+ return triples
256
+ except Exception as e:
257
+ print(f"Neocortex: semantic canonicalization failed ({e}), keeping new names.")
258
+ return triples
259
+
260
+
261
+ def _fetch_related_beliefs(names: list, user_id: str, limit: int = 30) -> list:
262
+ """Existing edges that touch any entity mentioned in the new facts."""
263
+ rows = neo4j_db.query(
264
+ """
265
+ MATCH (n:Entity)-[r]->(m:Entity)
266
+ WHERE n.user_id = $u AND m.user_id = $u
267
+ AND (n.name IN $names OR m.name IN $names)
268
+ RETURN n.name AS s, type(r) AS rel, m.name AS o
269
+ LIMIT $limit
270
+ """,
271
+ {"u": user_id, "names": names, "limit": limit},
272
+ ) or []
273
+ return [{"s": r["s"], "rel": r["rel"], "o": r["o"]} for r in rows]
274
+
275
+
276
+ def _reconcile(new_triples: list, existing: list) -> tuple:
277
+ """
278
+ One LLM call decides, for each new fact: ADD (genuinely new), SKIP
279
+ (already known), or REPLACE (contradicts existing beliefs, which get
280
+ deleted). Falls back to storing everything if the call fails, so a
281
+ flaky LLM can never lose new information.
282
+ Returns (triples_to_store, deletions) where each deletion pairs the
283
+ edge with the new fact that contradicts it: {"edge": ..., "new": ...}.
284
+ """
285
+ e_lines = "\n".join(f"E{i+1}: {e['s']} {e['rel']} {e['o']}" for i, e in enumerate(existing))
286
+ n_lines = "\n".join(f"N{i+1}: {t['subject']} {t['relation']} {t['object']}" for i, t in enumerate(new_triples))
287
+
288
+ prompt = f"""You maintain a person's memory. Compare each NEW fact against the EXISTING beliefs.
289
+
290
+ EXISTING beliefs:
291
+ {e_lines}
292
+
293
+ NEW facts:
294
+ {n_lines}
295
+
296
+ For each NEW fact choose exactly ONE action:
297
+ - "ADD": new information not covered by any existing belief.
298
+ - "SKIP": same meaning as an existing belief (already known).
299
+ - "REPLACE": contradicts or updates existing belief(s). Put those belief ids in "deletes".
300
+
301
+ Two facts conflict when they cannot both be true at once: opposite feelings about the same thing (LOVES vs HATES), a new home city, a changed job or status. Facts about different topics never conflict.
302
+ Treat singular/plural or spelling variants of a name as the SAME thing: "LILY" and "LILIES" are one concept, so LIKES LILY conflicts with HATES LILIES.
303
+
304
+ Return ONLY JSON in this exact format:
305
+ {{"decisions": [{{"fact": "N1", "action": "REPLACE", "deletes": ["E2"]}}, {{"fact": "N2", "action": "ADD", "deletes": []}}]}}"""
306
+
307
+ to_store, to_delete = [], []
308
+ try:
309
+ data = _extract_json(_judge_complete(prompt), "decisions")
310
+
311
+ decisions = {}
312
+ for d in data.get("decisions", []):
313
+ fact_id = str(d.get("fact", "")).upper().replace("N", "")
314
+ if fact_id.isdigit():
315
+ decisions[int(fact_id) - 1] = d
316
+
317
+ for i, t in enumerate(new_triples):
318
+ d = decisions.get(i, {})
319
+ action = str(d.get("action", "ADD")).upper()
320
+ if action == "SKIP":
321
+ continue
322
+ if action == "REPLACE":
323
+ for e_id in d.get("deletes", []) or []:
324
+ e_idx = str(e_id).upper().replace("E", "")
325
+ if e_idx.isdigit() and 0 < int(e_idx) <= len(existing):
326
+ to_delete.append({"edge": existing[int(e_idx) - 1], "new": t})
327
+ to_store.append(t)
328
+ return to_store, to_delete
329
+ except Exception as e:
330
+ print(f"Neocortex: reconciliation failed ({e}), storing all new facts.")
331
+ return new_triples, []
332
+
333
+
334
+ def extract_and_store_knowledge(text: str, user_id: str = "default_user", context: str = ""):
335
  """
336
  Child-brain knowledge extraction with 100% structurally guaranteed JSON output.
337
+
338
  Reads a conversation and extracts simple, clean concept associations —
339
  the way a child's brain naturally builds connections between ideas.
340
+
341
+ `context` is the assistant message the user was replying to. With it,
342
+ short answers like "umm tomato" become extractable facts, because the
343
+ question ("What's your favorite topping?") supplies the missing meaning.
344
  """
345
  if not neo4j_db.driver:
346
  print("Knowledge Graph disabled (No DB connection).")
347
  return 0
348
+
 
 
 
349
  clean = _clean_text(text)
350
+ ctx = _clean_text(context)[-500:] if context else ""
351
 
352
+ # Without context, a fact needs at least 3 words ("I like apples").
353
+ # With context, even a one-word reply can carry a fact.
354
+ min_words = 1 if ctx else 3
355
+ if len(clean.split()) < min_words:
356
  print(f"Neocortex: Input too short ({len(clean.split())} words), skipping.")
357
  return 0
358
 
359
  owner = user_id.upper()
360
 
361
+ if ctx:
362
+ text_block = f'Soma asked: "{ctx}"\n{owner} replied: "{clean}"'
363
+ context_rule = f"""
364
+ 6. The reply may be a short answer to Soma's question. Combine the question and the reply to infer the fact. Example: if Soma asked "What's your favorite topping?" and the reply is "umm tomato", extract {owner} --LIKES--> TOMATO."""
365
+ else:
366
+ text_block = clean
367
+ context_rule = ""
368
+
369
  prompt = f"""You are a child's brain learning about the world. Read the text and pick out SIMPLE facts as connections between concepts.
370
 
371
  Think like a child drawing a mind-map:
 
374
  - Focus on: people, places, things, hobbies, foods, animals, feelings, skills, jobs
375
 
376
  RULES:
377
+ 1. Nodes must be 1-3 word concept names, ALL CAPS, in SINGULAR form. Example: "CRICKET", "DELHI", "MOM", "LILY" (not "LILIES"), "BOOK" (not "BOOKS")
378
  2. Relations must be simple verbs: LIKES, IS_A, LIVES_IN, PLAYS, WORKS_AT, HAS, KNOWS, STUDIES, etc.
379
  3. "I" or "my" in the text refers to "{owner}" — always use "{owner}" as the node name for the speaker.
380
  4. DO NOT create nodes named "USER", "SOMA", "AI", "ASSISTANT", or any chat/bot terms.
381
+ 5. If the text is just greetings or small talk with zero factual content, return an empty triples list.{context_rule}
382
 
383
  Text:
384
+ {text_block}
385
 
386
  Return the extracted facts ONLY as a valid JSON block in this exact format:
387
  {{
 
392
  Do not write any other explanation or thoughts outside the JSON block. If there are no facts, return: {{"triples": []}}"""
393
 
394
  try:
395
+ content = _llm_complete(prompt, FAST_MODEL).strip()
396
+
397
+ data = _extract_json(content, "triples")
398
+ if not data:
 
 
 
 
 
 
 
 
 
 
 
399
  print("Neocortex: No JSON block found in LLM response.")
400
  return 0
401
+ triples_data = data.get("triples", [])
402
 
403
  if not triples_data:
404
  print("Neocortex: No triples extracted.")
405
  return 0
406
 
407
+ # ── Validate and clean the extracted triples ──
408
+ new_triples = []
409
+ seen = set()
410
  for t in triples_data:
411
  subj = str(t.get("subject", "")).strip().upper()
412
  rel = _sanitize_relation(str(t.get("relation", "")))
413
  obj = str(t.get("object", "")).strip().upper()
414
+
 
415
  if not _is_valid_node(subj) or not _is_valid_node(obj):
416
  continue
417
  if subj == obj: # Self-loops are meaningless
418
  continue
419
+ if (subj, rel, obj) in seen:
420
+ continue
421
+ seen.add((subj, rel, obj))
422
+ new_triples.append({"subject": subj, "relation": rel, "object": obj})
423
+
424
+ if not new_triples:
425
+ return 0
426
+
427
+ # ── Reconcile with existing beliefs before writing ──
428
+ new_triples = _canonicalize_nodes(new_triples, user_id) # spelling/plural variants (free)
429
+ new_triples = _semantic_canonicalize(new_triples, user_id) # same meaning, different words
430
+ names = list({n for t in new_triples for n in (t["subject"], t["object"])})
431
+ existing = _fetch_related_beliefs(names, user_id)
432
+ if existing:
433
+ to_store, to_delete = _reconcile(new_triples, existing)
434
+ else:
435
+ to_store, to_delete = new_triples, []
436
+
437
+ deleted = set()
438
+ for pair in to_delete:
439
+ edge, new_fact = pair["edge"], pair["new"]
440
+ key = (edge["s"], edge["rel"], edge["o"])
441
+ if key in deleted:
442
+ continue
443
+ if _can_coexist(new_fact, edge):
444
+ print(f"Neocortex: kept '{edge['s']} {edge['rel']} {edge['o']}' — no real conflict with new fact.")
445
+ continue
446
+ neo4j_db.query(
447
+ """
448
+ MATCH (s:Entity {name: $s, user_id: $u})-[r]->(o:Entity {name: $o, user_id: $u})
449
+ WHERE type(r) = $rel
450
+ DELETE r
451
+ """,
452
+ {"s": edge["s"], "o": edge["o"], "rel": edge["rel"], "u": user_id},
453
+ )
454
+ deleted.add(key)
455
+ if deleted:
456
+ print(f"Neocortex: belief revision removed {len(deleted)} outdated relation(s).")
457
+
458
+ stored_count = 0
459
+ for t in to_store:
460
  cypher = f"""
461
  MERGE (s:Entity {{name: $subject, user_id: $user_id}})
462
  MERGE (o:Entity {{name: $object, user_id: $user_id}})
463
+ MERGE (s)-[r:`{t["relation"]}`]->(o)
464
  """
465
+ neo4j_db.query(cypher, {"subject": t["subject"], "object": t["object"], "user_id": user_id})
466
  stored_count += 1
467
+
468
+ # Replaced edges can leave nodes with no connections — remove them so
469
+ # the mesh doesn't show floating orphans.
470
+ if deleted:
471
+ neo4j_db.query(
472
+ "MATCH (n:Entity) WHERE n.user_id = $u AND NOT (n)--() DELETE n",
473
+ {"u": user_id},
474
+ )
475
+
476
  return stored_count
477
  except Exception as e:
478
  print(f"Neocortex extraction error: {e}")
frontend/package-lock.json CHANGED
@@ -11,7 +11,8 @@
11
  "react": "^18.3.1",
12
  "react-dom": "^18.3.1",
13
  "react-force-graph-2d": "^1.29.1",
14
- "react-force-graph-3d": "^1.29.1"
 
15
  },
16
  "devDependencies": {
17
  "@eslint/js": "^9.15.0",
@@ -3273,6 +3274,18 @@
3273
  "three": ">=0.179"
3274
  }
3275
  },
 
 
 
 
 
 
 
 
 
 
 
 
3276
  "node_modules/tinycolor2": {
3277
  "version": "1.6.0",
3278
  "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
 
11
  "react": "^18.3.1",
12
  "react-dom": "^18.3.1",
13
  "react-force-graph-2d": "^1.29.1",
14
+ "react-force-graph-3d": "^1.29.1",
15
+ "three-spritetext": "^1.10.0"
16
  },
17
  "devDependencies": {
18
  "@eslint/js": "^9.15.0",
 
3274
  "three": ">=0.179"
3275
  }
3276
  },
3277
+ "node_modules/three-spritetext": {
3278
+ "version": "1.10.0",
3279
+ "resolved": "https://registry.npmjs.org/three-spritetext/-/three-spritetext-1.10.0.tgz",
3280
+ "integrity": "sha512-t08iP1FCU1lQh8T5MmCpdijKgas8GDHJE0LqMGBuVu3xqMMpFnEZhTlih7FlxLPQizHIGoumUSpfOlY1GO/Tgg==",
3281
+ "license": "MIT",
3282
+ "engines": {
3283
+ "node": ">=12"
3284
+ },
3285
+ "peerDependencies": {
3286
+ "three": ">=0.86.0"
3287
+ }
3288
+ },
3289
  "node_modules/tinycolor2": {
3290
  "version": "1.6.0",
3291
  "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
frontend/package.json CHANGED
@@ -13,7 +13,8 @@
13
  "react": "^18.3.1",
14
  "react-dom": "^18.3.1",
15
  "react-force-graph-2d": "^1.29.1",
16
- "react-force-graph-3d": "^1.29.1"
 
17
  },
18
  "devDependencies": {
19
  "@eslint/js": "^9.15.0",
 
13
  "react": "^18.3.1",
14
  "react-dom": "^18.3.1",
15
  "react-force-graph-2d": "^1.29.1",
16
+ "react-force-graph-3d": "^1.29.1",
17
+ "three-spritetext": "^1.10.0"
18
  },
19
  "devDependencies": {
20
  "@eslint/js": "^9.15.0",
frontend/src/components/KnowledgeGraph.jsx CHANGED
@@ -1,6 +1,7 @@
1
  import { useEffect, useState, useRef } from 'react';
2
  import ForceGraph3D from 'react-force-graph-3d';
3
  import * as THREE from 'three';
 
4
  import { apiFetch } from '../api';
5
  import './KnowledgeGraph.css';
6
 
@@ -278,22 +279,45 @@ function KnowledgeGraph({ refreshTick }) {
278
  emissive: getNodeColor(node),
279
  emissiveIntensity: 0.45
280
  });
281
- return new THREE.Mesh(geom, mat);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  }}
283
 
284
- // Outlined holographic label rendered natively floating beside cursor on hover (100% stable!)
285
- nodeLabel={node => `
286
- <span style="color: ${getNodeColor(node)}; font-size: 12px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase;">
287
- ${node.label || node.id}
288
- </span>
289
- `}
290
-
291
- // Outlined relationship tag rendered on link hover (100% stable!)
292
- linkLabel={link => `
293
- <span style="color: #ff6b35; font-size: 10px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase;">
294
- ${link.label || 'RELATED_TO'}
295
- </span>
296
- `}
 
 
 
 
 
 
 
297
 
298
  nodeRelSize={3}
299
  linkColor={() => 'rgba(255, 107, 53, 0.25)'} // Soft, clean axon link fibers
 
1
  import { useEffect, useState, useRef } from 'react';
2
  import ForceGraph3D from 'react-force-graph-3d';
3
  import * as THREE from 'three';
4
+ import SpriteText from 'three-spritetext';
5
  import { apiFetch } from '../api';
6
  import './KnowledgeGraph.css';
7
 
 
279
  emissive: getNodeColor(node),
280
  emissiveIntensity: 0.45
281
  });
282
+ const sphere = new THREE.Mesh(geom, mat);
283
+
284
+ // Always-visible name tag floating above the sphere
285
+ const tag = new SpriteText((node.label || node.id).toUpperCase());
286
+ tag.color = getNodeColor(node);
287
+ tag.textHeight = 3.2;
288
+ tag.fontWeight = '700';
289
+ tag.backgroundColor = 'rgba(10, 10, 10, 0.55)';
290
+ tag.padding = 1.2;
291
+ tag.borderRadius = 2;
292
+ tag.position.y = size + 4;
293
+ tag.material.depthWrite = false; // keep tag readable through overlapping geometry
294
+
295
+ const group = new THREE.Group();
296
+ group.add(sphere);
297
+ group.add(tag);
298
+ return group;
299
  }}
300
 
301
+ // Always-visible relationship tag floating at the middle of each link
302
+ linkThreeObjectExtend={true}
303
+ linkThreeObject={link => {
304
+ const tag = new SpriteText((link.label || 'RELATED_TO').toUpperCase());
305
+ tag.color = '#ff9c7a';
306
+ tag.textHeight = 2.2;
307
+ tag.fontWeight = '700';
308
+ tag.backgroundColor = 'rgba(10, 10, 10, 0.45)';
309
+ tag.padding = 1;
310
+ tag.borderRadius = 2;
311
+ tag.material.depthWrite = false;
312
+ return tag;
313
+ }}
314
+ linkPositionUpdate={(sprite, { start, end }) => {
315
+ sprite.position.set(
316
+ start.x + (end.x - start.x) / 2,
317
+ start.y + (end.y - start.y) / 2,
318
+ start.z + (end.z - start.z) / 2
319
+ );
320
+ }}
321
 
322
  nodeRelSize={3}
323
  linkColor={() => 'rgba(255, 107, 53, 0.25)'} // Soft, clean axon link fibers
scratch/bench_retrieval_latency.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Benchmark memory retrieval latency — the source of the resume numbers.
2
+
3
+ Run from repo root: python scratch/bench_retrieval_latency.py
4
+ Measures warm-cache latency; first call is excluded as warmup.
5
+ """
6
+ import time
7
+ import statistics
8
+
9
+ from app.services.memory import retrieve_context, get_embeddings
10
+ from app.services.neocortex import retrieve_graph_context
11
+
12
+ QUERIES = [
13
+ "what do I like",
14
+ "tell me about cricket",
15
+ "who is komal",
16
+ "what did we discuss",
17
+ "my favorite food",
18
+ ]
19
+
20
+ get_embeddings()
21
+ retrieve_context("warmup", "default_user")
22
+ vector_times = []
23
+ for q in QUERIES:
24
+ t0 = time.perf_counter()
25
+ retrieve_context(q, "default_user")
26
+ vector_times.append((time.perf_counter() - t0) * 1000)
27
+
28
+ retrieve_graph_context("warmup", "default_user")
29
+ graph_times = []
30
+ for q in QUERIES:
31
+ t0 = time.perf_counter()
32
+ retrieve_graph_context(q, "default_user")
33
+ graph_times.append((time.perf_counter() - t0) * 1000)
34
+
35
+ print(f"Vector (ChromaDB, local): median {statistics.median(vector_times):.1f} ms "
36
+ f"| runs: {[f'{t:.0f}' for t in vector_times]}")
37
+ print(f"Graph (Neo4j Aura, cloud round-trip): median {statistics.median(graph_times):.1f} ms "
38
+ f"| runs: {[f'{t:.0f}' for t in graph_times]}")
scratch/run_direct_debug.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+ import json
4
+ import re
5
+ from langchain_groq import ChatGroq
6
+ from langchain_core.messages import HumanMessage
7
+
8
+ # Add project root to path
9
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
10
+
11
+ from app.core.config import settings
12
+ from app.services.neocortex import _is_valid_node, _sanitize_relation
13
+
14
+ def extract_and_debug(text: str, user_id: str = "default_user"):
15
+ api_key = settings.GROQ_API_KEY if settings.GROQ_API_KEY else "dummy_key"
16
+ llm = ChatGroq(model="llama-3.1-8b-instant", api_key=api_key)
17
+
18
+ owner = user_id.upper()
19
+ prompt = f"""You are a child's brain learning about the world. Read the text and pick out SIMPLE facts as connections between concepts.
20
+
21
+ Think like a child drawing a mind-map:
22
+ - "{owner}" is the person speaking. If they say "I like X" → {owner} --LIKES--> X
23
+ - Extract only SHORT concept names (1-3 words). Never use full sentences as names.
24
+ - Focus on: people, places, things, hobbies, foods, animals, feelings, skills, jobs
25
+
26
+ RULES:
27
+ 1. Nodes must be 1-3 word concept names, ALL CAPS. Example: "CRICKET", "DELHI", "MOM", "CODING"
28
+ 2. Relations must be simple verbs: LIKES, IS_A, LIVES_IN, PLAYS, WORKS_AT, HAS, KNOWS, STUDIES, etc.
29
+ 3. "I" or "my" in the text refers to "{owner}" — always use "{owner}" as the node name for the speaker.
30
+ 4. DO NOT create nodes named "USER", "SOMA", "AI", "ASSISTANT", or any chat/bot terms.
31
+ 5. If the text is just greetings or small talk with zero factual content, return an empty triples list.
32
+
33
+ Text:
34
+ {text}
35
+
36
+ Return the extracted facts ONLY as a valid JSON block in this exact format:
37
+ {{
38
+ "triples": [
39
+ {{"subject": "SUBJECT", "relation": "RELATION", "object": "OBJECT"}}
40
+ ]
41
+ }}
42
+ Do not write any other explanation or thoughts outside the JSON block. If there are no facts, return: {{"triples": []}}"""
43
+
44
+ response = llm.invoke([HumanMessage(content=prompt)])
45
+ content = response.content.strip()
46
+
47
+ print("RAW RESPONSE:")
48
+ print(content)
49
+
50
+ match = re.search(r'\{.*\}', content, re.DOTALL)
51
+ if match:
52
+ data = json.loads(match.group(0))
53
+ for t in data.get("triples", []):
54
+ subj = str(t.get("subject", "")).strip().upper()
55
+ rel = _sanitize_relation(str(t.get("relation", "")))
56
+ obj = str(t.get("object", "")).strip().upper()
57
+ print(f"\nTriple: {subj} --{rel}--> {obj}")
58
+ print(f" Valid Subject ({subj}): {_is_valid_node(subj)}")
59
+ print(f" Valid Object ({obj}): {_is_valid_node(obj)}")
60
+ print(f" Equal: {subj == obj}")
61
+
62
+ extract_and_debug("Dr. Aris is the creator of the Soma Project. The Soma Project is an advanced cognitive architecture.")
scratch/strip_coauthors.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import re
3
+
4
+ if __name__ == "__main__":
5
+ msg = sys.stdin.read()
6
+ # Remove lines that contain 'Co-Authored-By: Claude'
7
+ cleaned_lines = []
8
+ for line in msg.split('\n'):
9
+ if not re.search(r'Co-Authored-By: Claude', line, re.IGNORECASE):
10
+ cleaned_lines.append(line)
11
+
12
+ # Print the cleaned message
13
+ sys.stdout.write('\n'.join(cleaned_lines))
scratch/test_belief_revision.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test that a new sentiment (HATES) replaces the old one (LOVES) instead of coexisting."""
2
+ import sys
3
+ import os
4
+
5
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
6
+
7
+ from app.services.neocortex import extract_and_store_knowledge
8
+ from app.db.neo4j_driver import neo4j_db
9
+
10
+ TEST_USER = "test_belief_rev"
11
+
12
+
13
+ def show_graph(label):
14
+ res = neo4j_db.query(
15
+ "MATCH (n:Entity)-[r]->(m:Entity) WHERE n.user_id = $u AND m.user_id = $u "
16
+ "RETURN n.name AS s, type(r) AS rel, m.name AS o",
17
+ {"u": TEST_USER},
18
+ )
19
+ print(f"{label}:")
20
+ for row in res or []:
21
+ print(f" {row['s']} --{row['rel']}--> {row['o']}")
22
+ print()
23
+
24
+
25
+ def main():
26
+ if not neo4j_db.driver:
27
+ from app.core.config import settings
28
+ neo4j_db.connect(settings.NEO4J_URI, settings.NEO4J_USER, settings.NEO4J_PASSWORD)
29
+
30
+ print("--- Step 1: 'I love pizza' ---")
31
+ extract_and_store_knowledge("I love pizza", TEST_USER)
32
+ show_graph("Graph after step 1")
33
+
34
+ print("--- Step 2: 'I hate pizza' (should REPLACE the loves edge) ---")
35
+ extract_and_store_knowledge("I hate pizza", TEST_USER)
36
+ show_graph("Graph after step 2")
37
+
38
+ # Clean up test nodes
39
+ neo4j_db.query("MATCH (n:Entity) WHERE n.user_id = $u DETACH DELETE n", {"u": TEST_USER})
40
+ print("Test nodes cleaned up.")
41
+
42
+
43
+ if __name__ == "__main__":
44
+ main()
scratch/test_context_extraction.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test that short replies get extracted when the previous Soma question is passed as context."""
2
+ import sys
3
+ import os
4
+
5
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
6
+
7
+ from app.services.neocortex import extract_and_store_knowledge
8
+ from app.db.neo4j_driver import neo4j_db
9
+
10
+ TEST_USER = "test_topping_ctx"
11
+
12
+
13
+ def main():
14
+ if not neo4j_db.driver:
15
+ from app.core.config import settings
16
+ neo4j_db.connect(settings.NEO4J_URI, settings.NEO4J_USER, settings.NEO4J_PASSWORD)
17
+
18
+ soma_question = "That's awesome! I'm glad you love pizza. What's your favorite topping?"
19
+
20
+ print("--- Case 1: 'umm tomato' WITHOUT context (old behavior, should skip) ---")
21
+ count_no_ctx = extract_and_store_knowledge("umm tomato", TEST_USER)
22
+ print(f"Triples stored: {count_no_ctx}\n")
23
+
24
+ print("--- Case 2: 'umm tomato' WITH context (new behavior, should extract) ---")
25
+ count_ctx = extract_and_store_knowledge("umm tomato", TEST_USER, context=soma_question)
26
+ print(f"Triples stored: {count_ctx}\n")
27
+
28
+ print(f"Graph contents for user '{TEST_USER}':")
29
+ res = neo4j_db.query(
30
+ "MATCH (n:Entity)-[r]->(m:Entity) WHERE n.user_id = $u AND m.user_id = $u "
31
+ "RETURN n.name AS s, type(r) AS rel, m.name AS o",
32
+ {"u": TEST_USER},
33
+ )
34
+ for row in res or []:
35
+ print(f" {row['s']} --{row['rel']}--> {row['o']}")
36
+
37
+ # Clean up test nodes so they don't pollute the real graph
38
+ neo4j_db.query("MATCH (n:Entity) WHERE n.user_id = $u DETACH DELETE n", {"u": TEST_USER})
39
+ print("\nTest nodes cleaned up.")
40
+
41
+
42
+ if __name__ == "__main__":
43
+ main()
scratch/test_komal_fact.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+ import asyncio
4
+
5
+ # Add project root to path
6
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
7
+
8
+ from app.services.neocortex import extract_and_store_knowledge
9
+ from app.db.neo4j_driver import neo4j_db
10
+
11
+ async def main():
12
+ print("Testing neocortex on Komal fact...")
13
+
14
+ # Check driver
15
+ if not neo4j_db.driver:
16
+ from app.core.config import settings
17
+ neo4j_db.connect(settings.NEO4J_URI, settings.NEO4J_USER, settings.NEO4J_PASSWORD)
18
+
19
+ fact = "Komal likes playing cricket."
20
+ triples = extract_and_store_knowledge(fact, "komal")
21
+ print(f"Extracted count: {triples}")
22
+
23
+ if neo4j_db.driver:
24
+ print("\nCurrent DB Nodes for User 'komal':")
25
+ res = neo4j_db.query("MATCH (n) WHERE n.user_id = 'komal' RETURN n.name as name LIMIT 10")
26
+ for r in res:
27
+ print(r)
28
+
29
+ if __name__ == "__main__":
30
+ asyncio.run(main())
scratch/test_plural_reconcile.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test that singular/plural variants resolve to one node and contradictions still replace."""
2
+ import sys
3
+ import os
4
+
5
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
6
+
7
+ from app.services.neocortex import extract_and_store_knowledge, _normalize_name
8
+ from app.db.neo4j_driver import neo4j_db
9
+
10
+ TEST_USER = "test_lily"
11
+
12
+
13
+ def show_graph(label):
14
+ res = neo4j_db.query(
15
+ "MATCH (n:Entity)-[r]->(m:Entity) WHERE n.user_id = $u AND m.user_id = $u "
16
+ "RETURN n.name AS s, type(r) AS rel, m.name AS o",
17
+ {"u": TEST_USER},
18
+ )
19
+ print(f"{label}:")
20
+ for row in res or []:
21
+ print(f" {row['s']} --{row['rel']}--> {row['o']}")
22
+ print()
23
+
24
+
25
+ def main():
26
+ print("Unit check: normalize('LILIES') == normalize('LILY') ->",
27
+ _normalize_name("LILIES") == _normalize_name("LILY"))
28
+ print("Unit check: normalize('BOOKS') == normalize('BOOK') ->",
29
+ _normalize_name("BOOKS") == _normalize_name("BOOK"))
30
+ print("Unit check: 'GLASS' unharmed ->", _normalize_name("GLASS") == "GLASS")
31
+ print()
32
+
33
+ if not neo4j_db.driver:
34
+ from app.core.config import settings
35
+ neo4j_db.connect(settings.NEO4J_URI, settings.NEO4J_USER, settings.NEO4J_PASSWORD)
36
+
37
+ neo4j_db.query("MATCH (n:Entity) WHERE n.user_id = $u DETACH DELETE n", {"u": TEST_USER})
38
+
39
+ print("=== Step 1: 'I love lily' ===")
40
+ extract_and_store_knowledge("I love lily", TEST_USER)
41
+ show_graph("Graph")
42
+
43
+ print("=== Step 2: 'I hate lilies' (plural! should still REPLACE, one node) ===")
44
+ extract_and_store_knowledge("I hate lilies", TEST_USER)
45
+ show_graph("Graph")
46
+
47
+ neo4j_db.query("MATCH (n:Entity) WHERE n.user_id = $u DETACH DELETE n", {"u": TEST_USER})
48
+ print("Test nodes cleaned up.")
49
+
50
+
51
+ if __name__ == "__main__":
52
+ main()
scratch/test_reconciliation.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test the belief reconciliation pipeline end-to-end:
2
+ ADD new facts, SKIP known ones, REPLACE contradicted ones, canonicalize spelling variants."""
3
+ import sys
4
+ import os
5
+
6
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
7
+
8
+ from app.services.neocortex import extract_and_store_knowledge, _canonicalize_nodes
9
+ from app.db.neo4j_driver import neo4j_db
10
+
11
+ TEST_USER = "test_reconcile"
12
+
13
+
14
+ def show_graph(label):
15
+ res = neo4j_db.query(
16
+ "MATCH (n:Entity)-[r]->(m:Entity) WHERE n.user_id = $u AND m.user_id = $u "
17
+ "RETURN n.name AS s, type(r) AS rel, m.name AS o",
18
+ {"u": TEST_USER},
19
+ )
20
+ print(f"{label}:")
21
+ for row in res or []:
22
+ print(f" {row['s']} --{row['rel']}--> {row['o']}")
23
+ print()
24
+
25
+
26
+ def main():
27
+ if not neo4j_db.driver:
28
+ from app.core.config import settings
29
+ neo4j_db.connect(settings.NEO4J_URI, settings.NEO4J_USER, settings.NEO4J_PASSWORD)
30
+
31
+ # Start clean
32
+ neo4j_db.query("MATCH (n:Entity) WHERE n.user_id = $u DETACH DELETE n", {"u": TEST_USER})
33
+
34
+ print("=== Step 1: 'I love pizza and I live in Delhi' (all new -> ADD) ===")
35
+ extract_and_store_knowledge("I love pizza and I live in Delhi", TEST_USER)
36
+ show_graph("Graph")
37
+
38
+ print("=== Step 2: 'I hate pizza' (contradiction -> REPLACE) ===")
39
+ extract_and_store_knowledge("I hate pizza", TEST_USER)
40
+ show_graph("Graph")
41
+
42
+ print("=== Step 3: 'I really hate pizza' (already known -> SKIP, no duplicates) ===")
43
+ extract_and_store_knowledge("I really hate pizza", TEST_USER)
44
+ show_graph("Graph")
45
+
46
+ print("=== Step 4: 'I moved to Mumbai' (updates home city -> REPLACE + orphan cleanup) ===")
47
+ extract_and_store_knowledge("I moved to Mumbai", TEST_USER)
48
+ show_graph("Graph")
49
+
50
+ print("=== Step 5: entity resolution unit check ===")
51
+ fake = [{"subject": "TEST_RECONCILE", "relation": "ENJOYS", "object": "MUM_BAI"}]
52
+ fixed = _canonicalize_nodes(fake, TEST_USER)
53
+ print(f" 'MUM_BAI' canonicalized to: {fixed[0]['object']} (expected MUMBAI)")
54
+
55
+ # Clean up
56
+ neo4j_db.query("MATCH (n:Entity) WHERE n.user_id = $u DETACH DELETE n", {"u": TEST_USER})
57
+ print("\nTest nodes cleaned up.")
58
+
59
+
60
+ if __name__ == "__main__":
61
+ main()
scratch/test_robust_extraction.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+ import json
4
+ import re
5
+ from langchain_groq import ChatGroq
6
+ from langchain_core.messages import HumanMessage
7
+ from pydantic import BaseModel, Field
8
+ from typing import List
9
+
10
+ # Add project root to path
11
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12
+
13
+ from app.core.config import settings
14
+
15
+ class RelationshipTriple(BaseModel):
16
+ subject: str
17
+ relation: str
18
+ object: str
19
+
20
+ class KnowledgeGraphExtraction(BaseModel):
21
+ triples: List[RelationshipTriple]
22
+
23
+ def extract_knowledge_robust(text: str, user_id: str = "default_user"):
24
+ api_key = settings.GROQ_API_KEY if settings.GROQ_API_KEY else "dummy_key"
25
+ llm = ChatGroq(model="llama-3.1-8b-instant", api_key=api_key)
26
+
27
+ owner = user_id.upper()
28
+
29
+ prompt = f"""You are a child's brain learning about the world. Read the text and pick out SIMPLE facts as connections between concepts.
30
+
31
+ Think like a child drawing a mind-map:
32
+ - "{owner}" is the person speaking. If they say "I like X" → {owner} --LIKES--> X
33
+ - Extract only SHORT concept names (1-3 words). Never use full sentences as names.
34
+ - Focus on: people, places, things, hobbies, foods, animals, feelings, skills, jobs
35
+
36
+ RULES:
37
+ 1. Nodes must be 1-3 word concept names, ALL CAPS. Example: "CRICKET", "DELHI", "MOM", "CODING"
38
+ 2. Relations must be simple verbs: LIKES, IS_A, LIVES_IN, PLAYS, WORKS_AT, HAS, KNOWS, STUDIES, etc.
39
+ 3. "I" or "my" in the text refers to "{owner}" — always use "{owner}" as the node name for the speaker.
40
+ 4. DO NOT create nodes named "USER", "SOMA", "AI", "ASSISTANT", or any chat/bot terms.
41
+ 5. If the text is just greetings or small talk with zero factual content, return an empty triples list.
42
+
43
+ Text:
44
+ {text}
45
+
46
+ Return the extracted facts ONLY as a valid JSON block in this exact format:
47
+ {{
48
+ "triples": [
49
+ {{"subject": "SUBJECT", "relation": "RELATION", "object": "OBJECT"}}
50
+ ]
51
+ }}
52
+ Do not write any other explanation or thoughts outside the JSON block. If there are no facts, return: {{"triples": []}}"""
53
+
54
+ response = llm.invoke([HumanMessage(content=prompt)])
55
+ content = response.content.strip()
56
+
57
+ print("\n--- Raw LLM Response ---")
58
+ print(content)
59
+
60
+ # Extract JSON block using regex
61
+ match = re.search(r'\{.*\}', content, re.DOTALL)
62
+ if match:
63
+ json_str = match.group(0)
64
+ try:
65
+ data = json.loads(json_str)
66
+ triples = []
67
+ for t in data.get("triples", []):
68
+ triples.append(RelationshipTriple(
69
+ subject=t.get("subject", ""),
70
+ relation=t.get("relation", ""),
71
+ object=t.get("object", "")
72
+ ))
73
+ return triples
74
+ except Exception as e:
75
+ print("Failed to parse JSON:", e)
76
+ return []
77
+ else:
78
+ print("No JSON block found.")
79
+ return []
80
+
81
+ def main():
82
+ print("Testing robust extraction method...")
83
+ test_text = "Dr. Aris is the creator of the Soma Project. The Soma Project is an advanced cognitive architecture."
84
+ triples = extract_knowledge_robust(test_text, "komal")
85
+ print("\n--- Extracted Triples ---")
86
+ for t in triples:
87
+ print(f"- {t.subject} --{t.relation}--> {t.object}")
88
+
89
+ if __name__ == "__main__":
90
+ main()
scratch/test_semantic_alias.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test meaning-based entity resolution: synonyms merge, related-but-different things don't."""
2
+ import sys
3
+ import os
4
+
5
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
6
+
7
+ from app.services.neocortex import extract_and_store_knowledge
8
+ from app.db.neo4j_driver import neo4j_db
9
+
10
+ TEST_USER = "test_semantic"
11
+
12
+
13
+ def show_graph(label):
14
+ res = neo4j_db.query(
15
+ "MATCH (n:Entity)-[r]->(m:Entity) WHERE n.user_id = $u AND m.user_id = $u "
16
+ "RETURN n.name AS s, type(r) AS rel, m.name AS o",
17
+ {"u": TEST_USER},
18
+ )
19
+ print(f"{label}:")
20
+ for row in res or []:
21
+ print(f" {row['s']} --{row['rel']}--> {row['o']}")
22
+ print()
23
+
24
+
25
+ def main():
26
+ if not neo4j_db.driver:
27
+ from app.core.config import settings
28
+ neo4j_db.connect(settings.NEO4J_URI, settings.NEO4J_USER, settings.NEO4J_PASSWORD)
29
+
30
+ neo4j_db.query("MATCH (n:Entity) WHERE n.user_id = $u DETACH DELETE n", {"u": TEST_USER})
31
+
32
+ print("=== Step 1: 'I love soccer and I live in Delhi' ===")
33
+ extract_and_store_knowledge("I love soccer and I live in Delhi", TEST_USER)
34
+ show_graph("Graph")
35
+
36
+ print("=== Step 2: 'I hate football' (synonym! should hit the SOCCER node) ===")
37
+ extract_and_store_knowledge("I hate football", TEST_USER)
38
+ show_graph("Graph")
39
+
40
+ print("=== Step 3: 'I like pasta' (related to nothing — must be a NEW node) ===")
41
+ extract_and_store_knowledge("I like pasta", TEST_USER)
42
+ show_graph("Graph")
43
+
44
+ print("=== Step 4: 'I moved to Mumbai' (must NOT merge with Delhi, must replace LIVES_IN) ===")
45
+ extract_and_store_knowledge("I moved to Mumbai", TEST_USER)
46
+ show_graph("Graph")
47
+
48
+ neo4j_db.query("MATCH (n:Entity) WHERE n.user_id = $u DETACH DELETE n", {"u": TEST_USER})
49
+ print("Test nodes cleaned up.")
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()