synapse-recall 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- synapse/__init__.py +1 -0
- synapse/api/__init__.py +1 -0
- synapse/api/main.py +377 -0
- synapse/config.py +61 -0
- synapse/consolidation/__init__.py +1 -0
- synapse/consolidation/extractor.py +111 -0
- synapse/consolidation/llm_extractor.py +227 -0
- synapse/consolidation/models.py +62 -0
- synapse/consolidation/service.py +374 -0
- synapse/db/__init__.py +1 -0
- synapse/db/base.py +5 -0
- synapse/db/models.py +412 -0
- synapse/db/session.py +13 -0
- synapse/domain/__init__.py +1 -0
- synapse/domain/models.py +70 -0
- synapse/enrichment/__init__.py +1 -0
- synapse/enrichment/crustdata.py +105 -0
- synapse/enrichment/models.py +11 -0
- synapse/episodic/__init__.py +1 -0
- synapse/episodic/service.py +87 -0
- synapse/inference/__init__.py +1 -0
- synapse/inference/assertions.py +190 -0
- synapse/inference/chat.py +210 -0
- synapse/mcp/__init__.py +1 -0
- synapse/mcp/server.py +86 -0
- synapse/memory/__init__.py +1 -0
- synapse/memory/mem0_store.py +114 -0
- synapse/procedural/__init__.py +1 -0
- synapse/procedural/models.py +64 -0
- synapse/procedural/service.py +300 -0
- synapse/procedural/templates.py +87 -0
- synapse/recall/__init__.py +1 -0
- synapse/recall/answerability.py +98 -0
- synapse/recall/models.py +39 -0
- synapse/recall/service.py +268 -0
- synapse/semantic/__init__.py +1 -0
- synapse/semantic/repository.py +194 -0
- synapse/sources/__init__.py +1 -0
- synapse/sources/mock.py +631 -0
- synapse/sources/slack_export.py +357 -0
- synapse/sources/slack_import.py +105 -0
- synapse/worker.py +21 -0
- synapse_recall-0.1.0.dist-info/METADATA +539 -0
- synapse_recall-0.1.0.dist-info/RECORD +47 -0
- synapse_recall-0.1.0.dist-info/WHEEL +4 -0
- synapse_recall-0.1.0.dist-info/entry_points.txt +2 -0
- synapse_recall-0.1.0.dist-info/licenses/LICENSE +21 -0
synapse/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Synapse organizational memory POC."""
|
synapse/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""HTTP API package."""
|
synapse/api/main.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import AsyncIterator
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import Annotated, Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import uvicorn
|
|
8
|
+
from fastapi import Depends, FastAPI, Query
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from sqlalchemy import select, text
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
|
+
|
|
13
|
+
from synapse.config import get_settings
|
|
14
|
+
from synapse.consolidation.service import ConsolidationService
|
|
15
|
+
from synapse.db.models import GROUP_ID_LENGTH, ConsolidationRun, Episode
|
|
16
|
+
from synapse.db.session import engine, get_session
|
|
17
|
+
from synapse.episodic.service import SystemOneService
|
|
18
|
+
from synapse.inference.chat import LLMInterpreter
|
|
19
|
+
from synapse.memory.mem0_store import Mem0EpisodeStore
|
|
20
|
+
from synapse.procedural.models import (
|
|
21
|
+
DetectionResult,
|
|
22
|
+
PatternView,
|
|
23
|
+
SkillSpecView,
|
|
24
|
+
SkillStatusUpdate,
|
|
25
|
+
)
|
|
26
|
+
from synapse.procedural.service import ProceduralMemoryService, list_patterns, list_skills
|
|
27
|
+
from synapse.recall.models import RecallResult
|
|
28
|
+
from synapse.recall.service import RecallService
|
|
29
|
+
from synapse.semantic.repository import load_entities, load_relationships
|
|
30
|
+
from synapse.sources.mock import mock_events
|
|
31
|
+
|
|
32
|
+
GroupId = Annotated[
|
|
33
|
+
str,
|
|
34
|
+
Query(
|
|
35
|
+
min_length=1,
|
|
36
|
+
max_length=GROUP_ID_LENGTH,
|
|
37
|
+
description="Account the request reads from and writes to.",
|
|
38
|
+
),
|
|
39
|
+
]
|
|
40
|
+
"""Required on every route that touches account data.
|
|
41
|
+
|
|
42
|
+
It has no default on purpose. A default would make the common case work without
|
|
43
|
+
naming an account, and the first time someone forgot to pass one their data
|
|
44
|
+
would quietly land in whichever account the default named.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class HealthResponse(BaseModel):
|
|
49
|
+
status: str
|
|
50
|
+
database: str
|
|
51
|
+
inference: str
|
|
52
|
+
models: dict[str, str]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EpisodeResponse(BaseModel):
|
|
56
|
+
id: str
|
|
57
|
+
kind: str
|
|
58
|
+
source_type: str
|
|
59
|
+
content: str
|
|
60
|
+
summary: str | None
|
|
61
|
+
participants: list[str]
|
|
62
|
+
occurred_at: str
|
|
63
|
+
mem0_id: str | None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ReplayResponse(BaseModel):
|
|
67
|
+
processed: int
|
|
68
|
+
skipped: int
|
|
69
|
+
indexed: int
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RecallRequest(BaseModel):
|
|
73
|
+
query: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ConsolidationRunResponse(BaseModel):
|
|
77
|
+
id: str
|
|
78
|
+
status: str
|
|
79
|
+
stage: str
|
|
80
|
+
provider: str
|
|
81
|
+
episode_count: int
|
|
82
|
+
entity_count: int
|
|
83
|
+
relationship_count: int
|
|
84
|
+
graph_diff: dict[str, Any]
|
|
85
|
+
error: str | None
|
|
86
|
+
created_at: str
|
|
87
|
+
started_at: str | None
|
|
88
|
+
completed_at: str | None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class GraphNodeResponse(BaseModel):
|
|
92
|
+
id: str
|
|
93
|
+
kind: str
|
|
94
|
+
name: str
|
|
95
|
+
aliases: list[str]
|
|
96
|
+
description: str | None
|
|
97
|
+
attributes: dict[str, Any]
|
|
98
|
+
confidence: float
|
|
99
|
+
external_attributes: list["ExternalAttributeResponse"]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ExternalAttributeResponse(BaseModel):
|
|
103
|
+
provider: str
|
|
104
|
+
field_group: str
|
|
105
|
+
payload: dict[str, Any]
|
|
106
|
+
source_url: str | None
|
|
107
|
+
confidence: float
|
|
108
|
+
observed_at: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class GraphEvidenceResponse(BaseModel):
|
|
112
|
+
episode_id: str
|
|
113
|
+
source_type: str
|
|
114
|
+
occurred_at: str
|
|
115
|
+
excerpt: str
|
|
116
|
+
confidence: float
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class GraphEdgeResponse(BaseModel):
|
|
120
|
+
id: str
|
|
121
|
+
source: str
|
|
122
|
+
target: str
|
|
123
|
+
predicate: str
|
|
124
|
+
attributes: dict[str, Any]
|
|
125
|
+
confidence: float
|
|
126
|
+
valid_at: str
|
|
127
|
+
invalid_at: str | None
|
|
128
|
+
evidence: list[GraphEvidenceResponse]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class GraphResponse(BaseModel):
|
|
132
|
+
nodes: list[GraphNodeResponse]
|
|
133
|
+
edges: list[GraphEdgeResponse]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@asynccontextmanager
|
|
137
|
+
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
138
|
+
yield
|
|
139
|
+
await engine.dispose()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
app = FastAPI(title="Synapse API", version="0.1.0", lifespan=lifespan)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def inference_status() -> str:
|
|
146
|
+
settings = get_settings()
|
|
147
|
+
try:
|
|
148
|
+
async with httpx.AsyncClient(timeout=1.5) as client:
|
|
149
|
+
responses = await asyncio.gather(
|
|
150
|
+
client.get(f"{settings.chat_base_url.rstrip('/')}/models"),
|
|
151
|
+
client.get(f"{settings.embedding_base_url.rstrip('/')}/models"),
|
|
152
|
+
)
|
|
153
|
+
for response in responses:
|
|
154
|
+
response.raise_for_status()
|
|
155
|
+
except (httpx.HTTPError, OSError):
|
|
156
|
+
return "unavailable"
|
|
157
|
+
return "available"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.get("/api/health", response_model=HealthResponse)
|
|
161
|
+
async def health(session: Annotated[AsyncSession, Depends(get_session)]) -> HealthResponse:
|
|
162
|
+
await session.execute(text("SELECT 1"))
|
|
163
|
+
settings = get_settings()
|
|
164
|
+
inference = await inference_status()
|
|
165
|
+
return HealthResponse(
|
|
166
|
+
status="ok" if inference == "available" else "degraded",
|
|
167
|
+
database="available",
|
|
168
|
+
inference=inference,
|
|
169
|
+
models={
|
|
170
|
+
"chat": settings.chat_model,
|
|
171
|
+
"embedding": settings.embedding_model,
|
|
172
|
+
},
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@app.get("/api/episodes", response_model=list[EpisodeResponse])
|
|
177
|
+
async def list_episodes(
|
|
178
|
+
group_id: GroupId,
|
|
179
|
+
session: Annotated[AsyncSession, Depends(get_session)],
|
|
180
|
+
) -> list[EpisodeResponse]:
|
|
181
|
+
result = await session.scalars(
|
|
182
|
+
select(Episode)
|
|
183
|
+
.where(Episode.group_id == group_id)
|
|
184
|
+
.order_by(Episode.occurred_at.desc())
|
|
185
|
+
.limit(100)
|
|
186
|
+
)
|
|
187
|
+
return [
|
|
188
|
+
EpisodeResponse(
|
|
189
|
+
id=str(episode.id),
|
|
190
|
+
kind=episode.kind.value,
|
|
191
|
+
source_type=episode.source_type.value,
|
|
192
|
+
content=episode.content,
|
|
193
|
+
summary=episode.summary,
|
|
194
|
+
participants=episode.participants,
|
|
195
|
+
occurred_at=episode.occurred_at.isoformat(),
|
|
196
|
+
mem0_id=episode.mem0_id,
|
|
197
|
+
)
|
|
198
|
+
for episode in result
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.get("/api/config")
|
|
203
|
+
async def public_config() -> dict[str, Any]:
|
|
204
|
+
settings = get_settings()
|
|
205
|
+
return {
|
|
206
|
+
"memory_hit_threshold": settings.memory_hit_threshold,
|
|
207
|
+
"embedding_dimensions": settings.embedding_dimensions,
|
|
208
|
+
"chat_model": settings.chat_model,
|
|
209
|
+
"embedding_model": settings.embedding_model,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@app.post("/api/demo/replay", response_model=ReplayResponse)
|
|
214
|
+
async def replay_demo(
|
|
215
|
+
group_id: GroupId,
|
|
216
|
+
session: Annotated[AsyncSession, Depends(get_session)],
|
|
217
|
+
) -> ReplayResponse:
|
|
218
|
+
service = SystemOneService(LLMInterpreter(), Mem0EpisodeStore())
|
|
219
|
+
processed = 0
|
|
220
|
+
skipped = 0
|
|
221
|
+
indexed = 0
|
|
222
|
+
for event in mock_events():
|
|
223
|
+
episode, created = await service.process(event, group_id, session)
|
|
224
|
+
if created:
|
|
225
|
+
processed += 1
|
|
226
|
+
else:
|
|
227
|
+
skipped += 1
|
|
228
|
+
indexed += int(episode.mem0_id is not None)
|
|
229
|
+
return ReplayResponse(processed=processed, skipped=skipped, indexed=indexed)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@app.post("/api/recall", response_model=RecallResult)
|
|
233
|
+
async def recall(
|
|
234
|
+
request: RecallRequest,
|
|
235
|
+
group_id: GroupId,
|
|
236
|
+
session: Annotated[AsyncSession, Depends(get_session)],
|
|
237
|
+
) -> RecallResult:
|
|
238
|
+
return await RecallService().recall(request.query, group_id, session)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@app.post("/api/procedural/detect", response_model=DetectionResult)
|
|
242
|
+
async def detect_procedural_patterns(
|
|
243
|
+
group_id: GroupId,
|
|
244
|
+
session: Annotated[AsyncSession, Depends(get_session)],
|
|
245
|
+
) -> DetectionResult:
|
|
246
|
+
return await ProceduralMemoryService().detect(group_id, session)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@app.get("/api/procedural/patterns", response_model=list[PatternView])
|
|
250
|
+
async def get_procedural_patterns(
|
|
251
|
+
group_id: GroupId,
|
|
252
|
+
session: Annotated[AsyncSession, Depends(get_session)],
|
|
253
|
+
) -> list[PatternView]:
|
|
254
|
+
return await list_patterns(group_id, session)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@app.get("/api/procedural/skills", response_model=list[SkillSpecView])
|
|
258
|
+
async def get_procedural_skills(
|
|
259
|
+
group_id: GroupId,
|
|
260
|
+
session: Annotated[AsyncSession, Depends(get_session)],
|
|
261
|
+
) -> list[SkillSpecView]:
|
|
262
|
+
return await list_skills(group_id, session)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@app.post("/api/procedural/skills/{skill_id}/status", response_model=SkillSpecView)
|
|
266
|
+
async def update_procedural_skill_status(
|
|
267
|
+
skill_id: str,
|
|
268
|
+
update: SkillStatusUpdate,
|
|
269
|
+
group_id: GroupId,
|
|
270
|
+
session: Annotated[AsyncSession, Depends(get_session)],
|
|
271
|
+
) -> SkillSpecView:
|
|
272
|
+
return await ProceduralMemoryService().set_status(
|
|
273
|
+
skill_id, update.status, group_id, session
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def consolidation_response(run: ConsolidationRun) -> ConsolidationRunResponse:
|
|
278
|
+
return ConsolidationRunResponse(
|
|
279
|
+
id=str(run.id),
|
|
280
|
+
status=run.status,
|
|
281
|
+
stage=run.stage,
|
|
282
|
+
provider=run.provider,
|
|
283
|
+
episode_count=run.episode_count,
|
|
284
|
+
entity_count=run.entity_count,
|
|
285
|
+
relationship_count=run.relationship_count,
|
|
286
|
+
graph_diff=run.graph_diff,
|
|
287
|
+
error=run.error,
|
|
288
|
+
created_at=run.created_at.isoformat(),
|
|
289
|
+
started_at=run.started_at.isoformat() if run.started_at else None,
|
|
290
|
+
completed_at=run.completed_at.isoformat() if run.completed_at else None,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@app.post("/api/system2/consolidate", response_model=ConsolidationRunResponse)
|
|
295
|
+
async def consolidate(
|
|
296
|
+
group_id: GroupId,
|
|
297
|
+
session: Annotated[AsyncSession, Depends(get_session)],
|
|
298
|
+
) -> ConsolidationRunResponse:
|
|
299
|
+
run = await ConsolidationService().run(group_id, session)
|
|
300
|
+
return consolidation_response(run)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@app.get("/api/system2/runs", response_model=list[ConsolidationRunResponse])
|
|
304
|
+
async def list_consolidation_runs(
|
|
305
|
+
group_id: GroupId,
|
|
306
|
+
session: Annotated[AsyncSession, Depends(get_session)],
|
|
307
|
+
) -> list[ConsolidationRunResponse]:
|
|
308
|
+
runs = await session.scalars(
|
|
309
|
+
select(ConsolidationRun)
|
|
310
|
+
.where(ConsolidationRun.group_id == group_id)
|
|
311
|
+
.order_by(ConsolidationRun.created_at.desc())
|
|
312
|
+
.limit(25)
|
|
313
|
+
)
|
|
314
|
+
return [consolidation_response(run) for run in runs]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@app.get("/api/system2/graph", response_model=GraphResponse)
|
|
318
|
+
async def get_graph(
|
|
319
|
+
group_id: GroupId,
|
|
320
|
+
session: Annotated[AsyncSession, Depends(get_session)],
|
|
321
|
+
) -> GraphResponse:
|
|
322
|
+
entities = await load_entities(group_id, session)
|
|
323
|
+
relationships = await load_relationships(group_id, session)
|
|
324
|
+
return GraphResponse(
|
|
325
|
+
nodes=[
|
|
326
|
+
GraphNodeResponse(
|
|
327
|
+
id=str(entity.id),
|
|
328
|
+
kind=entity.kind,
|
|
329
|
+
name=entity.canonical_name,
|
|
330
|
+
aliases=[alias.alias for alias in entity.aliases],
|
|
331
|
+
description=entity.description,
|
|
332
|
+
attributes=entity.attributes,
|
|
333
|
+
confidence=entity.confidence,
|
|
334
|
+
external_attributes=[
|
|
335
|
+
ExternalAttributeResponse(
|
|
336
|
+
provider=external.provider,
|
|
337
|
+
field_group=external.field_group,
|
|
338
|
+
payload=external.payload,
|
|
339
|
+
source_url=external.source_url,
|
|
340
|
+
confidence=external.confidence,
|
|
341
|
+
observed_at=external.observed_at.isoformat(),
|
|
342
|
+
)
|
|
343
|
+
for external in entity.external_attributes
|
|
344
|
+
],
|
|
345
|
+
)
|
|
346
|
+
for entity in entities
|
|
347
|
+
],
|
|
348
|
+
edges=[
|
|
349
|
+
GraphEdgeResponse(
|
|
350
|
+
id=str(relationship.id),
|
|
351
|
+
source=str(relationship.subject_id),
|
|
352
|
+
target=str(relationship.object_id),
|
|
353
|
+
predicate=relationship.predicate,
|
|
354
|
+
attributes=relationship.attributes,
|
|
355
|
+
confidence=relationship.confidence,
|
|
356
|
+
valid_at=relationship.valid_at.isoformat(),
|
|
357
|
+
invalid_at=(
|
|
358
|
+
relationship.invalid_at.isoformat() if relationship.invalid_at else None
|
|
359
|
+
),
|
|
360
|
+
evidence=[
|
|
361
|
+
GraphEvidenceResponse(
|
|
362
|
+
episode_id=str(evidence.episode_id),
|
|
363
|
+
source_type=evidence.episode.source_type.value,
|
|
364
|
+
occurred_at=evidence.episode.occurred_at.isoformat(),
|
|
365
|
+
excerpt=evidence.excerpt,
|
|
366
|
+
confidence=evidence.confidence,
|
|
367
|
+
)
|
|
368
|
+
for evidence in relationship.evidence
|
|
369
|
+
],
|
|
370
|
+
)
|
|
371
|
+
for relationship in relationships
|
|
372
|
+
],
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def run() -> None:
|
|
377
|
+
uvicorn.run("synapse.api.main:app", host="0.0.0.0", port=8000)
|
synapse/config.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Configuration, including which model server answers each kind of call.
|
|
2
|
+
|
|
3
|
+
Inference is addressed as two OpenAI-compatible endpoints — one for chat, one
|
|
4
|
+
for embeddings — rather than as a named provider. The defaults point at a local
|
|
5
|
+
Cactus serving Gemma, which is how the project is meant to be run, but nothing
|
|
6
|
+
below the settings knows that: pointing the URLs at a hosted gateway and
|
|
7
|
+
supplying its key is the whole of what switching providers involves.
|
|
8
|
+
|
|
9
|
+
Chat and embeddings are configured separately because they are not always the
|
|
10
|
+
same service. A gateway that serves chat may serve no embedding model at all,
|
|
11
|
+
leaving embeddings to a second deployment with its own URL, key, and dimension.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from functools import lru_cache
|
|
15
|
+
|
|
16
|
+
from pydantic import Field
|
|
17
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Settings(BaseSettings):
|
|
21
|
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
22
|
+
|
|
23
|
+
app_name: str = "Synapse"
|
|
24
|
+
environment: str = "development"
|
|
25
|
+
database_url: str = "postgresql+asyncpg://synapse:synapse@localhost:5432/synapse"
|
|
26
|
+
chat_base_url: str = "http://localhost:8080/v1"
|
|
27
|
+
embedding_base_url: str = "http://localhost:8080/v1"
|
|
28
|
+
chat_model: str = "google/gemma-4-E2B-it"
|
|
29
|
+
embedding_model: str = "google/gemma-4-E2B-it"
|
|
30
|
+
# A local Cactus ignores the bearer token, so the default is a placeholder
|
|
31
|
+
# rather than a secret. A hosted endpoint checks it.
|
|
32
|
+
chat_api_key: str = "cactus-local"
|
|
33
|
+
embedding_api_key: str = "cactus-local"
|
|
34
|
+
embedding_dimensions: int = 1536
|
|
35
|
+
# Extra completion budget for models that think before answering. Every
|
|
36
|
+
# call site sizes its own budget for the answer it expects, which is all a
|
|
37
|
+
# model like Gemma spends tokens on. A reasoning model bills its private
|
|
38
|
+
# reasoning against the same budget and can exhaust it before writing a
|
|
39
|
+
# single character, so those budgets need to be raised — by a lot, and
|
|
40
|
+
# without changing what each call site asks for. Set this to a few thousand
|
|
41
|
+
# when pointing at one; it costs nothing on a model that doesn't reason,
|
|
42
|
+
# which stops when it is done rather than when it hits the cap.
|
|
43
|
+
chat_reasoning_tokens: int = Field(default=0, ge=0)
|
|
44
|
+
inference_mode: str = "auto"
|
|
45
|
+
consolidation_mode: str = "auto"
|
|
46
|
+
crustdata_api_key: str | None = None
|
|
47
|
+
crustdata_base_url: str = "https://api.crustdata.com"
|
|
48
|
+
crustdata_api_version: str = "2025-11-01"
|
|
49
|
+
enrichment_cache_hours: int = Field(default=24, ge=1)
|
|
50
|
+
procedural_min_occurrences: int = Field(default=2, ge=2)
|
|
51
|
+
memory_hit_threshold: float = Field(default=0.62, ge=0, le=1)
|
|
52
|
+
mem0_collection: str = "synapse_episodes_gemma_1536"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def mem0_database_url(self) -> str:
|
|
56
|
+
return self.database_url.replace("postgresql+asyncpg://", "postgresql://", 1)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@lru_cache
|
|
60
|
+
def get_settings() -> Settings:
|
|
61
|
+
return Settings()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""System 2 semantic consolidation."""
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from synapse.consolidation.models import ConsolidationPlan, EntitySpec, RelationshipSpec
|
|
2
|
+
from synapse.db.models import Episode
|
|
3
|
+
from synapse.domain.models import semantic_assertions
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def normalize_name(value: str) -> str:
|
|
7
|
+
return " ".join(value.casefold().split())
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def entity_key(kind: str, name: str) -> str:
|
|
11
|
+
return f"{kind}:{normalize_name(name)}"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def derive_semantic_plan(episodes: list[Episode]) -> ConsolidationPlan:
|
|
15
|
+
entities: dict[str, EntitySpec] = {}
|
|
16
|
+
relationships: dict[tuple[str, str, str], RelationshipSpec] = {}
|
|
17
|
+
|
|
18
|
+
def add_entity(
|
|
19
|
+
kind: str,
|
|
20
|
+
name: str,
|
|
21
|
+
*,
|
|
22
|
+
aliases: list[str] | None = None,
|
|
23
|
+
description: str | None = None,
|
|
24
|
+
attributes: dict[str, str] | None = None,
|
|
25
|
+
confidence: float = 1.0,
|
|
26
|
+
) -> str:
|
|
27
|
+
key = entity_key(kind, name)
|
|
28
|
+
current = entities.get(key)
|
|
29
|
+
if current is None:
|
|
30
|
+
entities[key] = EntitySpec(
|
|
31
|
+
key=key,
|
|
32
|
+
kind=kind,
|
|
33
|
+
canonical_name=name,
|
|
34
|
+
aliases=aliases or [],
|
|
35
|
+
description=description,
|
|
36
|
+
attributes=attributes or {},
|
|
37
|
+
confidence=confidence,
|
|
38
|
+
)
|
|
39
|
+
else:
|
|
40
|
+
current.aliases = sorted(set(current.aliases) | set(aliases or []))
|
|
41
|
+
current.attributes.update(attributes or {})
|
|
42
|
+
current.description = current.description or description
|
|
43
|
+
current.confidence = max(current.confidence, confidence)
|
|
44
|
+
return key
|
|
45
|
+
|
|
46
|
+
def add_relationship(
|
|
47
|
+
subject_key: str,
|
|
48
|
+
predicate: str,
|
|
49
|
+
object_key: str,
|
|
50
|
+
source_id: str | None,
|
|
51
|
+
*,
|
|
52
|
+
attributes: dict[str, str] | None = None,
|
|
53
|
+
confidence: float = 1.0,
|
|
54
|
+
) -> None:
|
|
55
|
+
identity = (subject_key, predicate, object_key)
|
|
56
|
+
current = relationships.get(identity)
|
|
57
|
+
if current is None:
|
|
58
|
+
relationships[identity] = RelationshipSpec(
|
|
59
|
+
subject_key=subject_key,
|
|
60
|
+
predicate=predicate,
|
|
61
|
+
object_key=object_key,
|
|
62
|
+
evidence_source_ids=[source_id] if source_id else [],
|
|
63
|
+
attributes=attributes or {},
|
|
64
|
+
confidence=confidence,
|
|
65
|
+
)
|
|
66
|
+
return
|
|
67
|
+
if source_id and source_id not in current.evidence_source_ids:
|
|
68
|
+
current.evidence_source_ids.append(source_id)
|
|
69
|
+
current.attributes.update(attributes or {})
|
|
70
|
+
current.confidence = max(current.confidence, confidence)
|
|
71
|
+
|
|
72
|
+
for episode in episodes:
|
|
73
|
+
for participant in episode.participants:
|
|
74
|
+
add_entity("Person", participant)
|
|
75
|
+
|
|
76
|
+
assertions = semantic_assertions(episode.details)
|
|
77
|
+
for entity_assertion in assertions.entities:
|
|
78
|
+
add_entity(
|
|
79
|
+
entity_assertion.kind,
|
|
80
|
+
entity_assertion.name,
|
|
81
|
+
aliases=entity_assertion.aliases,
|
|
82
|
+
description=entity_assertion.description,
|
|
83
|
+
attributes=entity_assertion.attributes,
|
|
84
|
+
confidence=entity_assertion.confidence,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
for relationship_assertion in assertions.relationships:
|
|
88
|
+
subject_key = add_entity(
|
|
89
|
+
relationship_assertion.subject_kind,
|
|
90
|
+
relationship_assertion.subject_name,
|
|
91
|
+
)
|
|
92
|
+
object_key = add_entity(
|
|
93
|
+
relationship_assertion.object_kind,
|
|
94
|
+
relationship_assertion.object_name,
|
|
95
|
+
)
|
|
96
|
+
add_relationship(
|
|
97
|
+
subject_key,
|
|
98
|
+
relationship_assertion.predicate,
|
|
99
|
+
object_key,
|
|
100
|
+
episode.source_id,
|
|
101
|
+
attributes=relationship_assertion.attributes,
|
|
102
|
+
confidence=relationship_assertion.confidence,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return ConsolidationPlan(
|
|
106
|
+
entities=sorted(entities.values(), key=lambda item: item.key),
|
|
107
|
+
relationships=sorted(
|
|
108
|
+
relationships.values(),
|
|
109
|
+
key=lambda item: (item.subject_key, item.predicate, item.object_key),
|
|
110
|
+
),
|
|
111
|
+
)
|