Skip to content

Commit 34b3fd0

Browse files
SonAIengineclaude
andcommitted
feat: Graph API 확장 — list/update/maintain/add_turn + 커스텀 NodeKind (v0.8.0)
사용자 피드백 5건 반영: 1. graph.list(kind=, level=, limit=) — 전체 노드 조회 + 필터링 2. graph.update(node_id, title=, content=, ...) — 노드 부분 업데이트 3. MaintenanceResult + graph.maintain() — consolidate+decay+prune 통합 (기존 API 유지) 4. graph.add_turn(user_msg, asst_msg, session_id=) — 대화 세션/턴 헬퍼 5. Node.kind를 str로 확장 — 커스텀 kind("culture", "preference" 등) 자유 사용 - 전 backend _safe_node_kind() 헬퍼로 DB 저장/복원 안전 처리 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 451f7a6 commit 34b3fd0

File tree

12 files changed

+321
-16
lines changed

12 files changed

+321
-16
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "synaptic-memory"
7-
version = "0.7.0"
7+
version = "0.8.0"
88
description = "Brain-inspired knowledge graph for LLM agents — PPR, evidence chain, auto-ontology (rule/embedding/LLM), Hebbian learning, memory consolidation."
99
license = "MIT"
1010
requires-python = ">=3.12"

src/synaptic/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
EdgeKind,
6161
EvidenceChain,
6262
EvidenceStep,
63+
MaintenanceResult,
6364
Node,
6465
NodeKind,
6566
SearchResult,
@@ -82,7 +83,7 @@
8283
)
8384
from synaptic.resonance import ResonanceWeights
8485

85-
__version__ = "0.6.0"
86+
__version__ = "0.8.0"
8687

8788
__all__ = [
8889
"ActivatedNode",
@@ -100,6 +101,7 @@
100101
"EmbeddingRelationDetector",
101102
"GraphTraversal",
102103
"KindClassifier",
104+
"MaintenanceResult",
103105
"MockEmbeddingProvider",
104106
"Node",
105107
"NodeKind",

src/synaptic/backends/composite.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ async def delete_node(self, node_id: str) -> None:
147147
async def list_nodes(
148148
self,
149149
*,
150-
kind: NodeKind | None = None,
150+
kind: str | NodeKind | None = None,
151151
level: ConsolidationLevel | None = None,
152152
limit: int = 100,
153153
) -> list[Node]:

src/synaptic/backends/memory.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ async def delete_node(self, node_id: str) -> None:
5555
async def list_nodes(
5656
self,
5757
*,
58-
kind: NodeKind | None = None,
58+
kind: str | NodeKind | None = None,
5959
level: ConsolidationLevel | None = None,
6060
limit: int = 100,
6161
) -> list[Node]:

src/synaptic/backends/neo4j.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ async def delete_node(self, node_id: str) -> None:
143143
async def list_nodes(
144144
self,
145145
*,
146-
kind: NodeKind | None = None,
146+
kind: str | NodeKind | None = None,
147147
level: ConsolidationLevel | None = None,
148148
limit: int = 100,
149149
) -> list[Node]:
@@ -481,14 +481,22 @@ def _node_to_props(node: Node) -> dict[str, object]:
481481
}
482482

483483

484+
def _safe_node_kind(value: str) -> str | NodeKind:
485+
"""Convert to NodeKind if known, otherwise keep as raw string."""
486+
try:
487+
return NodeKind(value)
488+
except ValueError:
489+
return value
490+
491+
484492
def _record_to_node(data: object) -> Node:
485493
"""Convert Neo4j node record to Node dataclass."""
486494
# neo4j driver returns dict-like objects
487495
d: dict[str, object] = dict(data) if not isinstance(data, dict) else data # type: ignore[arg-type]
488496
props_raw = d.get("properties_json", "{}")
489497
return Node(
490498
id=str(d.get("id", "")),
491-
kind=NodeKind(str(d.get("kind", "concept"))),
499+
kind=_safe_node_kind(str(d.get("kind", "concept"))),
492500
title=str(d.get("title", "")),
493501
content=str(d.get("content", "")),
494502
tags=json.loads(str(d.get("tags_json", "[]"))),

src/synaptic/backends/postgresql.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ async def delete_node(self, node_id: str) -> None:
213213
async def list_nodes(
214214
self,
215215
*,
216-
kind: NodeKind | None = None,
216+
kind: str | NodeKind | None = None,
217217
level: ConsolidationLevel | None = None,
218218
limit: int = 100,
219219
) -> list[Node]:
@@ -441,12 +441,20 @@ async def decay_vitality(self, *, factor: float = 0.95) -> int:
441441
return int(result.split()[-1]) if result else 0
442442

443443

444+
def _safe_node_kind(value: str) -> str | NodeKind:
445+
"""Convert to NodeKind if known, otherwise keep as raw string."""
446+
try:
447+
return NodeKind(value)
448+
except ValueError:
449+
return value
450+
451+
444452
def _row_to_node(row: asyncpg.Record) -> Node:
445453
tags = list(row["tags"]) if row["tags"] else []
446454
props_raw = row.get("properties_json", "{}")
447455
return Node(
448456
id=row["id"],
449-
kind=NodeKind(row["kind"]),
457+
kind=_safe_node_kind(row["kind"]),
450458
title=row["title"],
451459
content=row["content"],
452460
tags=tags,

src/synaptic/backends/sqlite.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ async def delete_node(self, node_id: str) -> None:
180180
async def list_nodes(
181181
self,
182182
*,
183-
kind: NodeKind | None = None,
183+
kind: str | NodeKind | None = None,
184184
level: ConsolidationLevel | None = None,
185185
limit: int = 100,
186186
) -> list[Node]:
@@ -359,11 +359,19 @@ async def decay_vitality(self, *, factor: float = 0.95) -> int:
359359
return int(count)
360360

361361

362+
def _safe_node_kind(value: str) -> str | NodeKind:
363+
"""Convert to NodeKind if known, otherwise keep as raw string."""
364+
try:
365+
return NodeKind(value)
366+
except ValueError:
367+
return value
368+
369+
362370
def _row_to_node(row: aiosqlite.Row) -> Node:
363371
props_raw = row["properties_json"] if "properties_json" in row.keys() else "{}"
364372
return Node(
365373
id=row["id"],
366-
kind=NodeKind(row["kind"]),
374+
kind=_safe_node_kind(row["kind"]),
367375
title=row["title"],
368376
content=row["content"],
369377
tags=json.loads(row["tags_json"]),

src/synaptic/graph.py

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
Edge,
2222
EdgeKind,
2323
EvidenceChain,
24+
MaintenanceResult,
2425
Node,
2526
NodeKind,
2627
SearchResult,
@@ -229,7 +230,7 @@ async def add(
229230
title: str,
230231
content: str,
231232
*,
232-
kind: NodeKind | None = None,
233+
kind: str | NodeKind | None = None,
233234
tags: list[str] | None = None,
234235
source: str = "",
235236
embedding: list[float] | None = None,
@@ -363,6 +364,16 @@ async def agent_search(
363364
depth=depth,
364365
)
365366

367+
async def list(
368+
self,
369+
*,
370+
kind: str | NodeKind | None = None,
371+
level: ConsolidationLevel | None = None,
372+
limit: int = 100,
373+
) -> list[Node]:
374+
"""List all nodes with optional kind/level filtering."""
375+
return await self._backend.list_nodes(kind=kind, level=level, limit=limit)
376+
366377
async def get(self, node_id: str) -> Node | None:
367378
cached = self._cache.get(node_id)
368379
if cached is not None:
@@ -376,6 +387,39 @@ async def get(self, node_id: str) -> Node | None:
376387
self._cache.put(node)
377388
return node
378389

390+
async def update(
391+
self,
392+
node_id: str,
393+
*,
394+
title: str | None = None,
395+
content: str | None = None,
396+
kind: str | NodeKind | None = None,
397+
tags: list[str] | None = None,
398+
properties: dict[str, str] | None = None,
399+
embedding: list[float] | None = None,
400+
) -> Node | None:
401+
"""Update a node's fields by ID. Returns updated node, or None if not found."""
402+
node = await self._backend.get_node(node_id)
403+
if node is None:
404+
return None
405+
if title is not None:
406+
node.title = title
407+
if content is not None:
408+
node.content = content
409+
if kind is not None:
410+
node.kind = kind
411+
if tags is not None:
412+
node.tags = tags
413+
if properties is not None:
414+
node.properties = properties
415+
if embedding is not None:
416+
node.embedding = embedding
417+
node.updated_at = time()
418+
await self._backend.update_node(node)
419+
self._cache.invalidate(node_id)
420+
self._cache.put(node)
421+
return node
422+
379423
async def remove(self, node_id: str) -> bool:
380424
node = await self._backend.get_node(node_id)
381425
if node is None:
@@ -408,6 +452,20 @@ async def decay(self) -> int:
408452
self._cache.clear() # Vitality changed globally
409453
return await self._backend.decay_vitality(factor=0.95)
410454

455+
async def maintain(
456+
self,
457+
digester: Digester | None = None,
458+
*,
459+
context: dict[str, object] | None = None,
460+
) -> MaintenanceResult:
461+
"""Run consolidate + decay + prune in one call with a unified result."""
462+
consolidated = await self._consolidation.consolidate(
463+
self._backend, digester, context=context,
464+
)
465+
decayed = await self.decay()
466+
pruned = await self.prune()
467+
return MaintenanceResult(consolidated=consolidated, decayed=decayed, pruned=pruned)
468+
411469
async def export_markdown(self, *, node_ids: list[str] | None = None) -> str:
412470
return await self._md_exporter.export(self._backend, node_ids=node_ids)
413471

@@ -537,6 +595,79 @@ async def build_evidence(
537595
self._backend, query, search_result, max_steps=max_steps,
538596
)
539597

598+
# --- Conversation helpers ---
599+
600+
async def add_turn(
601+
self,
602+
user_msg: str,
603+
assistant_msg: str,
604+
*,
605+
session_id: str | None = None,
606+
tags: list[str] | None = None,
607+
) -> tuple[Node, Node, Node]:
608+
"""Add a conversation turn (user + assistant) linked to a session.
609+
610+
Creates a SESSION node on first call for a given session_id.
611+
Returns (session_node, user_node, assistant_node).
612+
"""
613+
from synaptic.models import _new_id # noqa: PLC0415
614+
615+
if session_id is None:
616+
session_id = f"session_{_new_id()}"
617+
618+
# Get or create session node
619+
session_node = await self._backend.get_node(session_id)
620+
if session_node is None:
621+
session_node = await self._store.add_node(
622+
f"Session {session_id[:8]}",
623+
"",
624+
kind=NodeKind.SESSION,
625+
tags=["_session"],
626+
source=session_id,
627+
)
628+
# Override the auto-generated ID with session_id
629+
await self._backend.delete_node(session_node.id)
630+
session_node.id = session_id
631+
await self._backend.save_node(session_node)
632+
633+
turn_tags = [*tags] if tags else []
634+
635+
# Create user message node
636+
user_node = await self._store.add_node(
637+
"user", user_msg, kind=NodeKind.OBSERVATION, tags=[*turn_tags, "_turn_user"],
638+
)
639+
640+
# Create assistant message node
641+
assistant_node = await self._store.add_node(
642+
"assistant", assistant_msg, kind=NodeKind.OBSERVATION, tags=[*turn_tags, "_turn_assistant"],
643+
)
644+
645+
# Link: user → assistant (FOLLOWED_BY)
646+
await self._store.add_edge(
647+
user_node.id, assistant_node.id, kind=EdgeKind.FOLLOWED_BY,
648+
)
649+
650+
# Link: session → user (CONTAINS)
651+
await self._store.add_edge(
652+
session_id, user_node.id, kind=EdgeKind.CONTAINS,
653+
)
654+
655+
# Link last turn to this one (FOLLOWED_BY)
656+
session_edges = await self._backend.get_edges(session_id, direction="outgoing")
657+
contained = [e for e in session_edges if e.kind == EdgeKind.CONTAINS and e.target_id != user_node.id]
658+
if contained:
659+
# Find the most recent contained user node
660+
last_user_id = contained[-1].target_id
661+
# Get the assistant node linked from last user
662+
last_edges = await self._backend.get_edges(last_user_id, direction="outgoing")
663+
last_assistant = [e for e in last_edges if e.kind == EdgeKind.FOLLOWED_BY]
664+
if last_assistant:
665+
await self._store.add_edge(
666+
last_assistant[-1].target_id, user_node.id, kind=EdgeKind.FOLLOWED_BY,
667+
)
668+
669+
return session_node, user_node, assistant_node
670+
540671
# --- Ontology persistence ---
541672

542673
async def save_ontology(self) -> None:

src/synaptic/models.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ class EdgeKind(StrEnum):
6868
@dataclass(slots=True)
6969
class Node:
7070
id: str = field(default_factory=_new_id)
71-
kind: NodeKind = NodeKind.CONCEPT
71+
kind: str = NodeKind.CONCEPT
7272
title: str = ""
7373
content: str = ""
7474
tags: list[str] = field(default_factory=_str_list)
@@ -131,6 +131,21 @@ class DigestResult:
131131
tokens_used: int = 0
132132

133133

134+
@dataclass(slots=True)
135+
class MaintenanceResult:
136+
"""Unified result for maintenance operations (consolidate + decay + prune)."""
137+
consolidated: DigestResult | None = None
138+
decayed: int = 0
139+
pruned: int = 0
140+
141+
@property
142+
def total_affected(self) -> int:
143+
count = self.decayed + self.pruned
144+
if self.consolidated:
145+
count += len(self.consolidated.nodes_created) + len(self.consolidated.nodes_updated)
146+
return count
147+
148+
134149
def _evidence_step_list() -> list["EvidenceStep"]:
135150
return []
136151

src/synaptic/protocols.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ async def delete_node(self, node_id: str) -> None: ...
3030
async def list_nodes(
3131
self,
3232
*,
33-
kind: NodeKind | None = None,
33+
kind: str | NodeKind | None = None,
3434
level: ConsolidationLevel | None = None,
3535
limit: int = 100,
3636
) -> list[Node]: ...

0 commit comments

Comments
 (0)