Spaces:
Sleeping
Sleeping
Komalpreet Kaur commited on
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 +10 -1
- app/db/session.py +6 -6
- app/services/neocortex.py +333 -34
- frontend/package-lock.json +14 -1
- frontend/package.json +2 -1
- frontend/src/components/KnowledgeGraph.jsx +38 -14
- scratch/bench_retrieval_latency.py +38 -0
- scratch/run_direct_debug.py +62 -0
- scratch/strip_coauthors.py +13 -0
- scratch/test_belief_revision.py +44 -0
- scratch/test_context_extraction.py +43 -0
- scratch/test_komal_fact.py +30 -0
- scratch/test_plural_reconcile.py +52 -0
- scratch/test_reconciliation.py +61 -0
- scratch/test_robust_extraction.py +90 -0
- scratch/test_semantic_alias.py +53 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 102 |
-
|
|
|
|
|
|
|
| 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", "
|
| 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 |
-
{
|
| 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 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 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 |
-
|
| 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:`{
|
| 173 |
"""
|
| 174 |
-
neo4j_db.query(cypher, {"subject":
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
}}
|
| 283 |
|
| 284 |
-
//
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 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()
|