diff --git a/README.ko.md b/README.ko.md index 078b0d5..0e7d508 100644 --- a/README.ko.md +++ b/README.ko.md @@ -15,12 +15,12 @@ LLM 에이전트용 지식 그래프 + MCP 도구 서버. 하이브리드 검색 ```bash pip install "synaptic-memory[sqlite,korean,vector]" -python examples/quickstart.py +synaptic-quickstart --db quickstart.db ``` -위 두 줄로 [`examples/data/products.csv`](examples/data/products.csv)를 -SQLite 기반 그래프로 인제스트하고 3개 쿼리를 실행합니다. 전 과정 **LLM 호출 0회**. -전체 소스: [`examples/quickstart.py`](examples/quickstart.py). +위 두 줄로 작은 SQLite 기반 그래프를 만들고 3개 쿼리를 실행합니다. 전 과정 +**LLM 호출 0회**입니다. `--db`를 빼면 의존성 없는 인메모리 smoke test로 +실행됩니다. 확장 예제: [`examples/quickstart.py`](examples/quickstart.py). --- @@ -32,36 +32,31 @@ from synaptic import SynapticGraph async def main(): # 아무 데이터 → 지식 그래프 (CSV, JSONL, 디렉터리) - graph = await SynapticGraph.from_data("./내_데이터/") - - # 또는 DB에서 바로 — SQLite / PostgreSQL / MySQL / Oracle / MSSQL - graph = await SynapticGraph.from_database( - "postgresql://user:pass@host:5432/dbname" - ) - - # 라이브 DB? CDC 모드로 변경분만 다시 읽기 - graph = await SynapticGraph.from_database( - "postgresql://user:pass@host:5432/dbname", - db="knowledge.db", - mode="cdc", # deterministic node ID + sync state 기록 - ) - result = await graph.sync_from_database( - "postgresql://user:pass@host:5432/dbname" - ) - print(result.added, result.updated, result.deleted) - - # 또는 직접 청킹한 문서 전달 (LangChain, Unstructured, 자체 OCR 등) - chunks = my_parser.split("manual.pdf") - graph = await SynapticGraph.from_chunks(chunks) - - # 검색 - result = await graph.search("내 질문", engine="evidence") + graph = await SynapticGraph.from_data("./내_데이터/", preset="rag") + try: + result = await graph.search("내 질문", engine="evidence") + print(result.nodes[0].node.title if result.nodes else "결과 없음") + finally: + await graph.close() asyncio.run(main()) ``` 파일 형식 또는 DB 스키마 자동 감지, 온톨로지 프로파일 자동 생성, 인제스트, 인덱싱, FK 엣지 구축까지 전부 자동. +자주 쓰는 옵션은 preset으로 줄일 수 있습니다. + +```python +# local: 기본값, 외부 서비스 없음 +graph = await SynapticGraph.from_chunks(chunks, preset="local") + +# rag: SYNAPTIC_EMBED_URL / SYNAPTIC_RERANK_URL 환경변수를 읽음 +graph = await SynapticGraph.from_data("./docs/", preset="rag") + +# agent: rag + 멀티턴 탐색용 deterministic component bridging +graph = await SynapticGraph.from_data("./docs/", preset="agent") +``` + > **라이브 DB 동기화 (CDC)** — `mode="cdc"`로 증분 업데이트: > `updated_at`류 컬럼이 있으면 워터마크 필터로 읽고, 없으면 row 내용 해시로 폴백. > 삭제는 TEMP TABLE + LEFT JOIN으로 감지, FK 변경 시 RELATED 엣지 재연결. @@ -83,7 +78,7 @@ asyncio.run(main()) ├─ 문서: Category → Document → Chunk └─ 정형: 테이블 row → ENTITY 노드 + RELATED 엣지 (FK) ↓ -42개 MCP 도구 → LLM 에이전트가 그래프 기반 멀티턴으로 탐색 +MCP 도구 → LLM 에이전트가 그래프 기반 멀티턴으로 탐색 ``` **라이브러리가 하는 건 딱 두 가지:** @@ -133,27 +128,20 @@ from synaptic import SynapticGraph async def main(): # CSV 파일 graph = await SynapticGraph.from_data("products.csv") - - # JSONL 문서 - graph = await SynapticGraph.from_data("documents.jsonl") - - # 디렉터리 전체 (CSV/JSONL 자동 스캔) - graph = await SynapticGraph.from_data("./내_코퍼스/") - - # 임베딩 추가 (선택, 의미 검색 품질 향상) - graph = await SynapticGraph.from_data( - "./내_코퍼스/", - embed_url="http://localhost:11434/v1", - ) - - # 검색 - result = await graph.search("내 질문", engine="evidence") - for activated in result.nodes[:5]: - print(activated.node.title, activated.activation) + try: + result = await graph.search("내 질문", engine="evidence") + for activated in result.nodes[:5]: + print(activated.node.title, activated.activation) + finally: + await graph.close() asyncio.run(main()) ``` +`preset="rag"`를 넘기면 `SYNAPTIC_EMBED_URL`, `SYNAPTIC_RERANK_URL`을 읽습니다. +여러 `from_data()`, `from_chunks()`, `from_database()` 호출에 같은 설정을 쓰고 +싶다면 `GraphBuildOptions`를 사용할 수 있습니다. + ### 방법 B: MCP 서버 (Claude Desktop / Code) ```bash @@ -161,7 +149,7 @@ synaptic-mcp --db my_graph.db synaptic-mcp --db my_graph.db --embed-url http://localhost:11434/v1 ``` -Claude가 42개 도구로 그래프를 직접 탐색합니다. 검색, 인제스트, CDC 동기화까지 CLI로 내려가지 않고 대화 안에서. +Claude가 MCP 도구로 그래프를 직접 탐색합니다. 검색, 인제스트, CDC 동기화까지 CLI로 내려가지 않고 대화 안에서. 복붙 가능한 `claude_desktop_config.json` 샘플: [`examples/mcp_claude_desktop.json`](examples/mcp_claude_desktop.json). diff --git a/README.md b/README.md index c13823a..b0456a7 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,13 @@ CDC-based live database sync, and Korean FTS built in. ```bash pip install "synaptic-memory[sqlite,korean,vector]" -python examples/quickstart.py +synaptic-quickstart --db quickstart.db ``` -That command ingests [`examples/data/products.csv`](examples/data/products.csv) -into a SQLite-backed graph and runs three searches — all **without calling -any LLM** at indexing time. Full source: [`examples/quickstart.py`](examples/quickstart.py). +That command builds a tiny SQLite-backed graph and runs three searches — all +**without calling any LLM** at indexing time. Omit `--db` for an in-memory, +zero-dependency smoke test. Full source for the expanded example: +[`examples/quickstart.py`](examples/quickstart.py). --- @@ -33,36 +34,31 @@ from synaptic import SynapticGraph async def main(): # Any data → knowledge graph (CSV, JSONL, directory) - graph = await SynapticGraph.from_data("./my_data/") - - # Or directly from a database — SQLite / PostgreSQL / MySQL / Oracle / MSSQL - graph = await SynapticGraph.from_database( - "postgresql://user:pass@host:5432/dbname" - ) - - # Live database? Use CDC mode and only re-read what changed. - graph = await SynapticGraph.from_database( - "postgresql://user:pass@host:5432/dbname", - db="knowledge.db", - mode="cdc", # deterministic node IDs + sync state recorded - ) - result = await graph.sync_from_database( - "postgresql://user:pass@host:5432/dbname" - ) - print(result.added, result.updated, result.deleted) - - # Or bring your own chunker (LangChain, Unstructured, custom OCR, ...) - chunks = my_parser.split("manual.pdf") - graph = await SynapticGraph.from_chunks(chunks) - - # Search - result = await graph.search("my question", engine="evidence") + graph = await SynapticGraph.from_data("./my_data/", preset="rag") + try: + result = await graph.search("my question", engine="evidence") + print(result.nodes[0].node.title if result.nodes else "no result") + finally: + await graph.close() asyncio.run(main()) ``` That's it. Auto-detects file format or DB schema, generates an ontology profile, ingests, indexes, builds FK edges. +Presets keep the common knobs compact: + +```python +# local: deterministic, no external services (default) +graph = await SynapticGraph.from_chunks(chunks, preset="local") + +# rag: reads SYNAPTIC_EMBED_URL / SYNAPTIC_RERANK_URL if set +graph = await SynapticGraph.from_data("./docs/", preset="rag") + +# agent: rag + deterministic component bridging for multi-turn exploration +graph = await SynapticGraph.from_data("./docs/", preset="agent") +``` + > **Live database sync (CDC)** — `mode="cdc"` enables incremental > updates: tables with an `updated_at`-style column are read with a > watermark filter, others fall back to per-row content hashing. @@ -86,7 +82,7 @@ Knowledge Graph ├─ Documents: Category → Document → Chunk └─ Structured: table rows as ENTITY nodes + RELATED edges (FKs) ↓ -42 MCP tools → LLM agent explores via graph-aware multi-turn tool use +MCP tools → LLM agent explores via graph-aware multi-turn tool use ``` **Two jobs, nothing else:** @@ -135,27 +131,21 @@ from synaptic import SynapticGraph async def main(): # CSV file graph = await SynapticGraph.from_data("products.csv") - - # JSONL documents - graph = await SynapticGraph.from_data("documents.jsonl") - - # Entire directory (scans all CSV/JSONL) - graph = await SynapticGraph.from_data("./my_corpus/") - - # With embedding (optional, improves semantic search) - graph = await SynapticGraph.from_data( - "./my_corpus/", - embed_url="http://localhost:11434/v1", - ) - - # Search - result = await graph.search("my question", engine="evidence") - for activated in result.nodes[:5]: - print(activated.node.title, activated.activation) + try: + result = await graph.search("my question", engine="evidence") + for activated in result.nodes[:5]: + print(activated.node.title, activated.activation) + finally: + await graph.close() asyncio.run(main()) ``` +You can pass `preset="rag"` to read `SYNAPTIC_EMBED_URL` and +`SYNAPTIC_RERANK_URL`, or use `GraphBuildOptions` when you want one reusable +configuration object across `from_data()`, `from_chunks()`, and +`from_database()`. + ### Option B: MCP server (Claude Desktop / Code) ```bash @@ -163,7 +153,7 @@ synaptic-mcp --db my_graph.db synaptic-mcp --db my_graph.db --embed-url http://localhost:11434/v1 ``` -Claude can now call 42 tools to explore your graph — search, ingest +Claude can now call MCP tools to explore your graph — search, ingest new files into the graph mid-conversation, and sync from a live database without dropping to a CLI. diff --git a/docs/ADOPTION.md b/docs/ADOPTION.md new file mode 100644 index 0000000..4a99e98 --- /dev/null +++ b/docs/ADOPTION.md @@ -0,0 +1,81 @@ +# Adoption Guide + +This guide is for developers adding Synaptic Memory to an existing RAG or +agent application. + +## Fast Smoke + +```bash +pip install synaptic-memory +synaptic-quickstart +``` + +The default quickstart uses an in-memory graph and has no optional dependency. +Persist the sample graph with SQLite: + +```bash +pip install "synaptic-memory[sqlite]" +synaptic-quickstart --db quickstart.db +``` + +## Recommended Installs + +```bash +pip install "synaptic-memory[sqlite,korean,vector]" +pip install "synaptic-memory[mcp]" +pip install "synaptic-memory[langchain]" +pip install "synaptic-memory[all]" +``` + +Use the narrowest extra set that matches your deployment. Core install stays +zero-dependency for tests and in-memory prototypes. + +## Python API + +```python +from synaptic import SynapticGraph + +graph = await SynapticGraph.from_data("./docs/", preset="rag") +try: + result = await graph.search("refund policy exception", engine="evidence") +finally: + await graph.close() +``` + +For pre-chunked data: + +```python +graph = await SynapticGraph.from_chunks( + [{"content": "...", "title": "Manual", "source": "manual.pdf"}], + preset="local", +) +``` + +## Presets + +| Preset | Use when | Behavior | +|---|---|---| +| `local` | tests, local smoke, deterministic baseline | no external endpoints | +| `rag` | normal RAG app | reads `SYNAPTIC_EMBED_URL` / `SYNAPTIC_RERANK_URL` | +| `agent` | multi-turn graph exploration | `rag` plus deterministic component bridging | +| `scale` | large ingest with external indexing plan | endpoint-aware, index/router configured separately | + +Use `GraphBuildOptions` when the same configuration should be reused across +`from_data()`, `from_chunks()`, and `from_database()`. + +```python +from synaptic import GraphBuildOptions, SynapticGraph + +options = GraphBuildOptions.rag().with_overrides(connect=True) +graph = await SynapticGraph.from_data("./docs/", options=options) +``` + +## Optional MCP Server + +```bash +pip install "synaptic-memory[mcp]" +synaptic-mcp --db knowledge.db +``` + +Without the `mcp` extra, `synaptic-mcp --help` still works and prints the +install command instead of failing with a Python traceback. diff --git a/docs/README.md b/docs/README.md index 94f13e5..e86b158 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,10 @@ Synaptic Memory가 **무엇이고, 왜 필요하고, 어떻게 쓰는지** 친 30분 안에 따라할 수 있는 단계별 실습. CSV 한 개부터 멀티 테이블 FK 관계, LLM 에이전트, MCP 서버까지 순서대로 경험합니다. +### [ADOPTION.md](ADOPTION.md) +기존 RAG/agent 애플리케이션에 붙일 때 필요한 설치 옵션, quickstart, +`preset`, `GraphBuildOptions`, MCP 진입점을 한 장으로 정리한 적용 가이드. + --- ## 🧠 내부 동작이 궁금한 분 @@ -46,9 +50,9 @@ Flash live eval 준비 상태를 정리합니다. ## 🗺 계획 / 로드맵 ### [ROADMAP.md](ROADMAP.md) -버전별 기능 추가 계획. 현재 PyPI 최신은 **v0.17.2** (Apache-2.0 라이선스 -전환 + agent_loop ID-extraction fix). **v0.18-alpha** 에서 `graph.chat()` -agent-loop 가 공개 API 로 승격됨. +v0.17~v0.18 시점의 역사적 로드맵과 측정 근거. 현재 설치/사용 흐름은 +루트 [`README.md`](../README.md), 변경 이력은 [`CHANGELOG.md`](../CHANGELOG.md), +최신 고도화 계획은 `PLAN-v0.30+` / `PLAN-v0.31+` 문서를 기준으로 보세요. ### [PLAN-v0.17-ontology.md](PLAN-v0.17-ontology.md) v0.17 ontology/정형 데이터 파이프라인 설계. MuSiQue 한계 논의 포함. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 996f9de..0b502f2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,11 +1,13 @@ -# Synaptic Memory — Roadmap +# Synaptic Memory — Historical Roadmap -> 마지막 업데이트: 2026-04-20 (v0.17.2 PyPI + v0.18-alpha 진행 중) +> 마지막 업데이트: 2026-04-20. 이 문서는 v0.17~v0.18 시점의 역사적 +> 로드맵입니다. 현재 설치/사용 흐름은 루트 README, 최신 변경 이력은 +> CHANGELOG, 이후 고도화 계획은 PLAN-v0.30+ / PLAN-v0.31+ 문서를 보세요. -## 현재 상태 +## 당시 상태 -- **PyPI stable**: [`synaptic-memory==0.17.2`](https://pypi.org/project/synaptic-memory/0.17.2/) -- **진행 트랙**: v0.18-alpha (`graph.chat()` agent-loop public API) +- **PyPI stable 당시 기준**: [`synaptic-memory==0.17.2`](https://pypi.org/project/synaptic-memory/0.17.2/) +- **당시 진행 트랙**: v0.18-alpha (`graph.chat()` agent-loop public API) - **라이선스**: Apache-2.0 (v0.17.2 에서 MIT → Apache-2.0 전환) - **테스트**: **942** 단위 테스트 통과 - **MCP 도구**: 36개 diff --git a/examples/langchain_retriever.py b/examples/langchain_retriever.py index 31acda6..34181d6 100644 --- a/examples/langchain_retriever.py +++ b/examples/langchain_retriever.py @@ -51,9 +51,7 @@ async def main() -> None: print(f" metadata keys: {sorted(doc.metadata.keys())}") print() finally: - close = getattr(graph._backend, "close", None) - if close is not None: - await close() + await graph.close() if __name__ == "__main__": diff --git a/examples/quickstart.py b/examples/quickstart.py index a807d19..5bf0d60 100644 --- a/examples/quickstart.py +++ b/examples/quickstart.py @@ -66,11 +66,7 @@ async def main() -> None: print(f" {i}. {name:<30} score={activated.activation:.3f}") print() finally: - # Close the backend connection cleanly so aiosqlite's worker - # thread exits before the event loop does. - close = getattr(graph._backend, "close", None) - if close is not None: - await close() + await graph.close() if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 8666059..1d4af7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,8 @@ Documentation = "https://github.com/PlateerLab/synaptic-memory/blob/main/README. Changelog = "https://github.com/PlateerLab/synaptic-memory/blob/main/CHANGELOG.md" [project.scripts] -synaptic-mcp = "synaptic.mcp.server:main" +synaptic-mcp = "synaptic.cli.mcp:main" +synaptic-quickstart = "synaptic.cli.quickstart:main" synaptic-snapshot = "synaptic.cli.snapshot:main" synaptic-agent-batch = "synaptic.cli.agent_batch:main" diff --git a/src/synaptic/__init__.py b/src/synaptic/__init__.py index 0759ef6..68e5c0a 100644 --- a/src/synaptic/__init__.py +++ b/src/synaptic/__init__.py @@ -102,6 +102,7 @@ TypeDef, build_agent_ontology, ) +from synaptic.options import GraphBuildOptions, SynapticPreset from synaptic.ppr import personalized_pagerank from synaptic.protocols import ( Digester, @@ -143,6 +144,7 @@ "EvidenceStep", "FeedbackSignal", "GraphTraversal", + "GraphBuildOptions", "HybridClassifier", "HybridEntityExtractor", "IndexFilter", @@ -194,6 +196,7 @@ "StorageBackend", "StorageCandidateProvider", "SynapticGraph", + "SynapticPreset", "TableIngester", "TagExtractor", "TypeDef", diff --git a/src/synaptic/__init__.pyi b/src/synaptic/__init__.pyi index b0e405f..651a1c8 100644 --- a/src/synaptic/__init__.pyi +++ b/src/synaptic/__init__.pyi @@ -93,6 +93,8 @@ from synaptic.ontology import PropertyDef as PropertyDef from synaptic.ontology import RelationConstraint as RelationConstraint from synaptic.ontology import TypeDef as TypeDef from synaptic.ontology import build_agent_ontology as build_agent_ontology +from synaptic.options import GraphBuildOptions as GraphBuildOptions +from synaptic.options import SynapticPreset as SynapticPreset from synaptic.ppr import personalized_pagerank as personalized_pagerank from synaptic.protocols import Digester as Digester from synaptic.protocols import EntityExtractor as EntityExtractor diff --git a/src/synaptic/cli/mcp.py b/src/synaptic/cli/mcp.py new file mode 100644 index 0000000..b4ec3c4 --- /dev/null +++ b/src/synaptic/cli/mcp.py @@ -0,0 +1,72 @@ +"""Friendly ``synaptic-mcp`` entrypoint with optional-extra guidance.""" + +from __future__ import annotations + +import sys + +from synaptic.mcp import __version__ + +HELP = """Usage: synaptic-mcp [OPTIONS] + +Start the Synaptic Memory MCP server. + +Options: + --db PATH SQLite database path for the graph (default: knowledge.db) + --dsn DSN PostgreSQL backend for the graph itself + --source-dsn DSN Default source database for CDC sync tools + --embed-url URL Embedding API base URL (OpenAI-compatible) + --embed-model NAME Embedding model name + --rerank-url URL Cross-encoder reranker server base URL + --rerank-backend NAME Reranker wire format: vllm, ollama, or tei + --rerank-model NAME Reranker model name + --llm-url URL LLM API base URL for knowledge_ask + --llm-model NAME LLM model name + --llm-api-key KEY API key for --llm-url + --vector-min-cosine FLOAT Absolute noise floor for vector cascade + --vector-relative-drop FLOAT + Relative vector cutoff below the top hit + --version Show version + -h, --help Show this help + +Install the MCP extra before running the server: + pip install "synaptic-memory[mcp]" +""" + + +def main(argv: list[str] | None = None) -> int: + args = sys.argv[1:] if argv is None else argv + + if "--version" in args: + print(f"synaptic-mcp {__version__}") + return 0 + if "--help" in args or "-h" in args: + print(HELP) + return 0 + + try: + from synaptic.mcp.server import main as server_main + except ModuleNotFoundError as exc: + if exc.name == "mcp": + print( + "synaptic-mcp requires the MCP extra.\n" + 'Install with: pip install "synaptic-memory[mcp]"', + file=sys.stderr, + ) + return 2 + raise + + old_argv = sys.argv + if argv is not None: + sys.argv = [old_argv[0], *argv] + try: + try: + server_main() + except SystemExit as exc: + return int(exc.code or 0) + return 0 + finally: + sys.argv = old_argv + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/synaptic/cli/quickstart.py b/src/synaptic/cli/quickstart.py new file mode 100644 index 0000000..183dd5a --- /dev/null +++ b/src/synaptic/cli/quickstart.py @@ -0,0 +1,213 @@ +"""``synaptic-quickstart`` CLI — build and query a tiny graph immediately.""" + +from __future__ import annotations + +import argparse +import asyncio +import csv +import json +import sys +import tempfile +from pathlib import Path +from typing import Any + +from synaptic import __version__ +from synaptic.models import NodeKind +from synaptic.options import DEFAULT_EMBED_MODEL, SynapticPreset + +SAMPLE_PRODUCTS: tuple[dict[str, str], ...] = ( + { + "name": "LunaBook Air 14", + "category": "laptop", + "description": "Lightweight laptop with long battery life for business travel.", + }, + { + "name": "Gochu Ramyun Kit", + "category": "food", + "description": "Spicy Korean noodles with gochujang broth and dried vegetables.", + }, + { + "name": "Cica Recovery Mask", + "category": "skincare", + "description": "Facial skincare sheet mask for calming sensitive skin.", + }, + { + "name": "Aurora Desk Lamp", + "category": "office", + "description": "Adjustable LED desk lamp with warm light and USB-C charging.", + }, + { + "name": "TrailBottle Steel", + "category": "outdoor", + "description": "Insulated stainless bottle that keeps drinks cold during hikes.", + }, +) + +DEFAULT_QUERIES = ( + "laptop with long battery", + "spicy Korean noodles", + "facial skincare mask", +) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="synaptic-quickstart", + description="Build a tiny Synaptic Memory graph and run a few searches.", + ) + parser.add_argument( + "--db", + type=Path, + default=None, + help="Persist the quickstart graph to this SQLite file. Requires the sqlite extra.", + ) + parser.add_argument( + "--keep-db", + action="store_true", + help="Persist to ./quickstart.db instead of using the zero-dependency in-memory path.", + ) + parser.add_argument( + "--preset", + choices=[preset.value for preset in SynapticPreset], + default=SynapticPreset.LOCAL.value, + help="Build preset for endpoint wiring (default: local).", + ) + parser.add_argument( + "--embed-url", + default=None, + help="OpenAI-compatible embedding base URL. Overrides preset/env.", + ) + parser.add_argument( + "--embed-model", + default=None, + help=f"Embedding model name (default: {DEFAULT_EMBED_MODEL}).", + ) + parser.add_argument( + "--rerank-url", + default=None, + help="Optional cross-encoder reranker base URL. Overrides preset/env.", + ) + parser.add_argument( + "--query", + action="append", + dest="queries", + default=None, + help="Query to run. Repeat for multiple queries. Defaults to three sample queries.", + ) + parser.add_argument( + "--json", + action="store_true", + help="Emit machine-readable JSON for smoke tests.", + ) + parser.add_argument("--version", action="version", version=f"synaptic-quickstart {__version__}") + return parser + + +async def _build_memory_graph() -> Any: + from synaptic import SynapticGraph + + graph = SynapticGraph.memory() + await graph.connect() + for product in SAMPLE_PRODUCTS: + await graph.add( + product["name"], + product["description"], + kind=NodeKind.ENTITY, + source="synaptic-quickstart", + properties=dict(product), + ) + return graph + + +async def _build_sqlite_graph(args: argparse.Namespace, db_path: Path) -> Any: + from synaptic import SynapticGraph + + with tempfile.TemporaryDirectory(prefix="synaptic-quickstart-") as tmpdir: + csv_path = Path(tmpdir) / "products.csv" + with csv_path.open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=["name", "category", "description"]) + writer.writeheader() + writer.writerows(SAMPLE_PRODUCTS) + return await SynapticGraph.from_data( + str(csv_path), + db=str(db_path), + preset=args.preset, + embed_url=args.embed_url, + embed_model=args.embed_model, + rerank_url=args.rerank_url, + ) + + +async def _run(args: argparse.Namespace) -> dict[str, Any]: + db_path = args.db or (Path.cwd() / "quickstart.db" if args.keep_db else None) + graph = ( + await _build_sqlite_graph(args, db_path) + if db_path is not None + else await _build_memory_graph() + ) + + try: + stats = await graph.stats() + queries = args.queries or list(DEFAULT_QUERIES) + query_results: list[dict[str, Any]] = [] + for query in queries: + result = await graph.search(query, limit=3) + hits = [] + for activated in result.nodes[:3]: + node = activated.node + hits.append( + { + "id": node.id, + "title": node.properties.get("name") or node.title, + "score": round(float(activated.activation), 6), + "category": node.properties.get("category", ""), + } + ) + query_results.append({"query": query, "hits": hits}) + + return { + "backend": "sqlite" if db_path is not None else "memory", + "db": str(db_path) if db_path is not None else "", + "node_count": int(stats.get("total_nodes", 0)), + "queries": query_results, + } + finally: + await graph.close() + + +def _print_text(payload: dict[str, Any]) -> None: + backend = payload["backend"] + db = f" ({payload['db']})" if payload.get("db") else "" + print(f"Synaptic quickstart ready: {payload['node_count']} nodes via {backend}{db}\n") + for item in payload["queries"]: + print(f"Query: {item['query']!r}") + for idx, hit in enumerate(item["hits"], 1): + category = f" [{hit['category']}]" if hit.get("category") else "" + print(f" {idx}. {hit['title']}{category} score={hit['score']:.3f}") + print() + + +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + try: + payload = asyncio.run(_run(args)) + except ModuleNotFoundError as exc: + if exc.name == "aiosqlite": + print( + "synaptic-quickstart --db requires the sqlite extra.\n" + 'Install with: pip install "synaptic-memory[sqlite]"', + file=sys.stderr, + ) + return 2 + raise + + if args.json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) + else: + _print_text(payload) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/synaptic/graph.py b/src/synaptic/graph.py index fbfac57..432e195 100644 --- a/src/synaptic/graph.py +++ b/src/synaptic/graph.py @@ -136,6 +136,7 @@ def _edge_content_hash(edge: Edge) -> str: memory_scope_key, ) from synaptic.ontology import OntologyRegistry, build_agent_ontology +from synaptic.options import GraphBuildOptions, SynapticPreset, resolve_graph_build_options from synaptic.protocols import ( Digester, EntityExtractor, @@ -770,15 +771,17 @@ async def from_data( *, db: str = "synaptic.db", backend: StorageBackend | None = None, + preset: SynapticPreset | str | None = None, + options: GraphBuildOptions | None = None, profile: object | None = None, openie_extractor: EntityExtractor | None = None, - openie_enabled: bool = False, + openie_enabled: bool | None = None, embed_url: str | None = None, - embed_model: str = "qwen3-embedding:4b", + embed_model: str | None = None, rerank_url: str | None = None, - rerank_backend: str = "vllm", - rerank_model: str = "BAAI/bge-reranker-v2-m3", - connect: bool = False, + rerank_backend: str | None = None, + rerank_model: str | None = None, + connect: bool | None = None, ) -> SynapticGraph: """ONE-LINE graph construction from any data source. @@ -801,6 +804,10 @@ async def from_data( graph = await SynapticGraph.from_data("./my_docs/") result = await graph.search("my question") + # Endpoint-aware defaults via env vars: + # SYNAPTIC_EMBED_URL, SYNAPTIC_RERANK_URL, ... + graph = await SynapticGraph.from_data("./my_docs/", preset="rag") + # With embedding graph = await SynapticGraph.from_data( "./data.csv", @@ -823,6 +830,11 @@ async def from_data( it instead of the default SQLite. When given, the caller owns its connection lifecycle. When omitted, a SQLite graph backend is created at ``db``. + preset: Optional named option bundle: ``"local"`` (default), + ``"rag"`` (read endpoint env vars), ``"agent"`` (RAG + plus deterministic component bridging), or ``"scale"``. + options: Optional :class:`GraphBuildOptions` instance for + reusing constructor options across ``from_*`` calls. profile: Optional :class:`DomainProfile` (or a TOML path). When omitted, a profile is auto-generated from samples — that builds the Category→Document→Chunk hierarchy but @@ -868,6 +880,18 @@ async def from_data( from synaptic.extensions.profile_generator import ProfileGenerator from synaptic.extensions.table_ingester import TableIngester + runtime = resolve_graph_build_options( + preset=preset, + options=options, + embed_url=embed_url, + embed_model=embed_model, + rerank_url=rerank_url, + rerank_backend=rerank_backend, + rerank_model=rerank_model, + openie_enabled=openie_enabled, + connect=connect, + ) + path = Path(data_path) backend = await cls._open_backend(backend, db) @@ -954,7 +978,7 @@ async def from_data( samples=samples, categories=categories if categories else None, ) - if openie_enabled and profile is not None: + if runtime.openie_enabled and profile is not None: profile.openie_enabled = True # type: ignore[attr-defined] # Ingest each file @@ -1027,12 +1051,12 @@ async def from_data( return await cls._finalize( backend, - embed_url=embed_url, - embed_model=embed_model, - rerank_url=rerank_url, - rerank_backend=rerank_backend, - rerank_model=rerank_model, - connect=connect, + embed_url=runtime.embed_url, + embed_model=runtime.embed_model, + rerank_url=runtime.rerank_url, + rerank_backend=runtime.rerank_backend, + rerank_model=runtime.rerank_model, + connect=runtime.connect, ) @classmethod @@ -1042,14 +1066,17 @@ async def from_chunks( *, db: str = "synaptic.db", backend: StorageBackend | None = None, + preset: SynapticPreset | str | None = None, + options: GraphBuildOptions | None = None, profile: object | None = None, openie_extractor: EntityExtractor | None = None, - openie_enabled: bool = False, + openie_enabled: bool | None = None, embed_url: str | None = None, - embed_model: str = "qwen3-embedding:4b", + embed_model: str | None = None, rerank_url: str | None = None, - rerank_backend: str = "vllm", - rerank_model: str = "BAAI/bge-reranker-v2-m3", + rerank_backend: str | None = None, + rerank_model: str | None = None, + connect: bool | None = None, ) -> SynapticGraph: """Ingest pre-parsed / pre-chunked documents directly. @@ -1080,6 +1107,9 @@ async def from_chunks( is auto-generated from the first 20 chunks. backend: Optional pre-built :class:`StorageBackend` (see :meth:`from_data`). Defaults to SQLite at ``db``. + preset / options: Reusable configuration for embedding, + reranking, OpenIE opt-in, and component bridging. See + :meth:`from_data`. openie_extractor: Optional opt-in OpenIE extractor. Runs only when ``openie_enabled=True`` or the supplied profile has ``openie_enabled=True``. @@ -1113,6 +1143,18 @@ async def from_chunks( ) from synaptic.extensions.profile_generator import ProfileGenerator + runtime = resolve_graph_build_options( + preset=preset, + options=options, + embed_url=embed_url, + embed_model=embed_model, + rerank_url=rerank_url, + rerank_backend=rerank_backend, + rerank_model=rerank_model, + openie_enabled=openie_enabled, + connect=connect, + ) + backend = await cls._open_backend(backend, db) # Auto-generate a profile from the first 20 chunks if the @@ -1126,7 +1168,7 @@ async def from_chunks( samples=samples, categories=list(dict.fromkeys(categories)) if categories else None, ) - if openie_enabled and profile is not None: + if runtime.openie_enabled and profile is not None: profile.openie_enabled = True # type: ignore[attr-defined] # Materialise chunks into a temp JSONL so they flow through @@ -1195,11 +1237,12 @@ async def from_chunks( return await cls._finalize( backend, - embed_url=embed_url, - embed_model=embed_model, - rerank_url=rerank_url, - rerank_backend=rerank_backend, - rerank_model=rerank_model, + embed_url=runtime.embed_url, + embed_model=runtime.embed_model, + rerank_url=runtime.rerank_url, + rerank_backend=runtime.rerank_backend, + rerank_model=runtime.rerank_model, + connect=runtime.connect, ) @classmethod @@ -1212,11 +1255,14 @@ async def from_database( tables: list[str] | None = None, row_limit: int = 500_000, mode: str = "full", + preset: SynapticPreset | str | None = None, + options: GraphBuildOptions | None = None, embed_url: str | None = None, - embed_model: str = "qwen3-embedding:4b", + embed_model: str | None = None, rerank_url: str | None = None, - rerank_backend: str = "vllm", - rerank_model: str = "BAAI/bge-reranker-v2-m3", + rerank_backend: str | None = None, + rerank_model: str | None = None, + connect: bool | None = None, ) -> SynapticGraph: """ONE-LINE graph construction from a relational database. @@ -1264,11 +1310,25 @@ async def from_database( Args: backend: Optional pre-built :class:`StorageBackend` for the graph store — see :meth:`from_data`. Defaults to SQLite. + preset / options: Reusable configuration for embedding, + reranking, and component bridging. OpenIE is ignored for + relational ingests. embed_url / rerank_url: Optional embedder / reranker server URLs — same semantics as :meth:`from_data`. """ from synaptic.extensions.db_ingester import DbIngester + runtime = resolve_graph_build_options( + preset=preset, + options=options, + embed_url=embed_url, + embed_model=embed_model, + rerank_url=rerank_url, + rerank_backend=rerank_backend, + rerank_model=rerank_model, + connect=connect, + ) + backend = await cls._open_backend(backend, db) graph = cls(backend) ingester = DbIngester() @@ -1384,11 +1444,12 @@ async def from_database( return await cls._finalize( backend, - embed_url=embed_url, - embed_model=embed_model, - rerank_url=rerank_url, - rerank_backend=rerank_backend, - rerank_model=rerank_model, + embed_url=runtime.embed_url, + embed_model=runtime.embed_model, + rerank_url=runtime.rerank_url, + rerank_backend=runtime.rerank_backend, + rerank_model=runtime.rerank_model, + connect=runtime.connect, ) # --- Synchronous constructors --- diff --git a/src/synaptic/mcp/__init__.py b/src/synaptic/mcp/__init__.py index d8d5e37..c574941 100644 --- a/src/synaptic/mcp/__init__.py +++ b/src/synaptic/mcp/__init__.py @@ -1,3 +1,3 @@ """Synaptic Memory MCP Server.""" -__version__ = "0.16.0" +from synaptic import __version__ as __version__ diff --git a/src/synaptic/options.py b/src/synaptic/options.py new file mode 100644 index 0000000..938c9ce --- /dev/null +++ b/src/synaptic/options.py @@ -0,0 +1,168 @@ +"""User-facing build options for SynapticGraph one-line constructors.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, replace +from enum import StrEnum + +DEFAULT_EMBED_MODEL = "qwen3-embedding:4b" +DEFAULT_RERANK_BACKEND = "vllm" +DEFAULT_RERANK_MODEL = "BAAI/bge-reranker-v2-m3" + + +class SynapticPreset(StrEnum): + """Named option bundles for the high-level graph constructors.""" + + LOCAL = "local" + RAG = "rag" + AGENT = "agent" + SCALE = "scale" + + +@dataclass(frozen=True, slots=True) +class GraphBuildOptions: + """Options shared by ``SynapticGraph.from_*`` constructors. + + The defaults are intentionally local and LLM-free. External embedding + and reranking endpoints are only enabled when a caller supplies them + directly or via ``SYNAPTIC_*`` environment variables in an endpoint-aware + preset. + """ + + embed_url: str | None = None + embed_model: str = DEFAULT_EMBED_MODEL + rerank_url: str | None = None + rerank_backend: str = DEFAULT_RERANK_BACKEND + rerank_model: str = DEFAULT_RERANK_MODEL + openie_enabled: bool = False + connect: bool = False + + @classmethod + def local(cls) -> GraphBuildOptions: + """No external services, no mutation beyond ingest.""" + + return cls() + + @classmethod + def rag(cls) -> GraphBuildOptions: + """Endpoint-aware RAG preset driven by ``SYNAPTIC_*`` env vars.""" + + return cls( + embed_url=_env_str("SYNAPTIC_EMBED_URL"), + embed_model=_env_str("SYNAPTIC_EMBED_MODEL") or DEFAULT_EMBED_MODEL, + rerank_url=_env_str("SYNAPTIC_RERANK_URL"), + rerank_backend=_env_str("SYNAPTIC_RERANK_BACKEND") or DEFAULT_RERANK_BACKEND, + rerank_model=_env_str("SYNAPTIC_RERANK_MODEL") or DEFAULT_RERANK_MODEL, + ) + + @classmethod + def agent(cls) -> GraphBuildOptions: + """RAG preset plus deterministic component bridging for exploration.""" + + return replace(cls.rag(), connect=True) + + @classmethod + def scale(cls) -> GraphBuildOptions: + """Endpoint-aware preset for large ingests; indexing is configured separately.""" + + return cls.rag() + + @classmethod + def from_preset(cls, preset: SynapticPreset | str | None = None) -> GraphBuildOptions: + """Build options from a preset name. + + ``None`` maps to ``local`` so existing constructor defaults remain + deterministic and dependency-free. + """ + + selected = _coerce_preset(preset) + if selected == SynapticPreset.LOCAL: + return cls.local() + if selected == SynapticPreset.RAG: + return cls.rag() + if selected == SynapticPreset.AGENT: + return cls.agent() + if selected == SynapticPreset.SCALE: + return cls.scale() + raise AssertionError(f"unhandled preset: {selected!r}") + + def with_overrides( + self, + *, + embed_url: str | None = None, + embed_model: str | None = None, + rerank_url: str | None = None, + rerank_backend: str | None = None, + rerank_model: str | None = None, + openie_enabled: bool | None = None, + connect: bool | None = None, + ) -> GraphBuildOptions: + """Return a copy with explicit constructor kwargs applied.""" + + updates: dict[str, object] = {} + if embed_url is not None: + updates["embed_url"] = embed_url + if embed_model is not None: + updates["embed_model"] = embed_model + if rerank_url is not None: + updates["rerank_url"] = rerank_url + if rerank_backend is not None: + updates["rerank_backend"] = rerank_backend + if rerank_model is not None: + updates["rerank_model"] = rerank_model + if openie_enabled is not None: + updates["openie_enabled"] = openie_enabled + if connect is not None: + updates["connect"] = connect + return replace(self, **updates) + + +def resolve_graph_build_options( + *, + preset: SynapticPreset | str | None = None, + options: GraphBuildOptions | None = None, + embed_url: str | None = None, + embed_model: str | None = None, + rerank_url: str | None = None, + rerank_backend: str | None = None, + rerank_model: str | None = None, + openie_enabled: bool | None = None, + connect: bool | None = None, +) -> GraphBuildOptions: + """Resolve constructor options with explicit kwargs taking precedence.""" + + if preset is not None and options is not None: + msg = "Pass either preset=... or options=..., not both" + raise ValueError(msg) + base = options if options is not None else GraphBuildOptions.from_preset(preset) + return base.with_overrides( + embed_url=embed_url, + embed_model=embed_model, + rerank_url=rerank_url, + rerank_backend=rerank_backend, + rerank_model=rerank_model, + openie_enabled=openie_enabled, + connect=connect, + ) + + +def _coerce_preset(preset: SynapticPreset | str | None) -> SynapticPreset: + if preset is None: + return SynapticPreset.LOCAL + if isinstance(preset, SynapticPreset): + return preset + try: + return SynapticPreset(str(preset).lower()) + except ValueError as exc: + allowed = ", ".join(p.value for p in SynapticPreset) + msg = f"Unknown Synaptic preset {preset!r}; expected one of: {allowed}" + raise ValueError(msg) from exc + + +def _env_str(name: str) -> str | None: + value = os.environ.get(name) + if value is None: + return None + value = value.strip() + return value or None diff --git a/tests/test_options.py b/tests/test_options.py new file mode 100644 index 0000000..cb87ed8 --- /dev/null +++ b/tests/test_options.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import sqlite3 + +import pytest + +from synaptic import GraphBuildOptions, SynapticGraph, SynapticPreset +from synaptic.options import resolve_graph_build_options + + +def test_local_options_are_dependency_free() -> None: + options = GraphBuildOptions.from_preset() + + assert options == GraphBuildOptions.local() + assert options.embed_url is None + assert options.rerank_url is None + assert options.openie_enabled is False + assert options.connect is False + + +def test_rag_preset_reads_endpoint_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SYNAPTIC_EMBED_URL", "http://embed.local/v1") + monkeypatch.setenv("SYNAPTIC_EMBED_MODEL", "custom-embed") + monkeypatch.setenv("SYNAPTIC_RERANK_URL", "http://rerank.local") + monkeypatch.setenv("SYNAPTIC_RERANK_BACKEND", "tei") + monkeypatch.setenv("SYNAPTIC_RERANK_MODEL", "custom-rerank") + + options = GraphBuildOptions.from_preset(SynapticPreset.RAG) + + assert options.embed_url == "http://embed.local/v1" + assert options.embed_model == "custom-embed" + assert options.rerank_url == "http://rerank.local" + assert options.rerank_backend == "tei" + assert options.rerank_model == "custom-rerank" + + +def test_agent_preset_enables_component_bridging() -> None: + options = GraphBuildOptions.from_preset("agent") + + assert options.connect is True + + +def test_preset_values_are_stable() -> None: + assert [preset.value for preset in SynapticPreset] == ["local", "rag", "agent", "scale"] + + +def test_invalid_preset_raises_clear_error() -> None: + with pytest.raises(ValueError, match="expected one of"): + GraphBuildOptions.from_preset("unknown") + + +def test_explicit_kwargs_override_options() -> None: + base = GraphBuildOptions( + embed_url="http://old-embed/v1", + rerank_url="http://old-rerank", + openie_enabled=False, + connect=True, + ) + + options = resolve_graph_build_options( + options=base, + embed_url="http://new-embed/v1", + openie_enabled=True, + connect=False, + ) + + assert options.embed_url == "http://new-embed/v1" + assert options.rerank_url == "http://old-rerank" + assert options.openie_enabled is True + assert options.connect is False + + +def test_preset_and_options_are_mutually_exclusive() -> None: + with pytest.raises(ValueError, match="either preset"): + resolve_graph_build_options(preset="rag", options=GraphBuildOptions.local()) + + +async def test_from_chunks_accepts_build_options(tmp_path) -> None: + graph = await SynapticGraph.from_chunks( + [{"content": "A tiny chunk for options smoke testing."}], + db=str(tmp_path / "options.db"), + options=GraphBuildOptions.local(), + ) + try: + assert graph._embedder is None + result = await graph.search("tiny chunk") + assert result.nodes + finally: + await graph.close() + + +async def test_from_data_accepts_preset(tmp_path) -> None: + csv_path = tmp_path / "products.csv" + csv_path.write_text( + "name,description\n" + "Alpha Lamp,USB-C desk lamp with warm light\n" + "Beta Bottle,Insulated bottle for hiking\n", + encoding="utf-8", + ) + + graph = await SynapticGraph.from_data( + str(csv_path), + db=str(tmp_path / "from_data.db"), + preset="local", + ) + try: + result = await graph.search("USB-C desk lamp") + assert result.nodes + finally: + await graph.close() + + +async def test_from_database_accepts_build_options(tmp_path) -> None: + pytest.importorskip("aiosqlite") + source_db = tmp_path / "source.db" + with sqlite3.connect(source_db) as conn: + conn.execute("CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, description TEXT)") + conn.execute( + "INSERT INTO products (id, name, description) VALUES (1, 'Alpha Lamp', 'USB-C desk lamp')" + ) + + graph = await SynapticGraph.from_database( + f"sqlite:///{source_db}", + db=str(tmp_path / "from_database.db"), + options=GraphBuildOptions.local(), + ) + try: + result = await graph.search("Alpha Lamp") + assert result.nodes + finally: + await graph.close() diff --git a/tests/test_quickstart_cli.py b/tests/test_quickstart_cli.py new file mode 100644 index 0000000..12c794a --- /dev/null +++ b/tests/test_quickstart_cli.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from synaptic.cli import mcp as mcp_cli +from synaptic.cli import quickstart + + +def test_quickstart_json_memory_backend(capsys: pytest.CaptureFixture[str]) -> None: + rc = quickstart.main(["--json"]) + + captured = capsys.readouterr() + payload = json.loads(captured.out) + assert rc == 0 + assert payload["backend"] == "memory" + assert payload["node_count"] >= 5 + assert payload["queries"] + assert payload["queries"][0]["hits"] + + +def test_quickstart_json_sqlite_backend( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + pytest.importorskip("aiosqlite") + db_path = tmp_path / "quickstart.db" + + rc = quickstart.main(["--json", "--db", str(db_path)]) + + captured = capsys.readouterr() + payload = json.loads(captured.out) + assert rc == 0 + assert payload["backend"] == "sqlite" + assert payload["db"] == str(db_path) + assert payload["node_count"] >= 5 + assert db_path.exists() + + +def test_quickstart_custom_query_and_preset(capsys: pytest.CaptureFixture[str]) -> None: + rc = quickstart.main(["--json", "--preset", "local", "--query", "USB-C desk lamp"]) + + captured = capsys.readouterr() + payload = json.loads(captured.out) + assert rc == 0 + assert [item["query"] for item in payload["queries"]] == ["USB-C desk lamp"] + assert payload["queries"][0]["hits"] + + +def test_mcp_help_does_not_require_mcp_extra(capsys: pytest.CaptureFixture[str]) -> None: + rc = mcp_cli.main(["--help"]) + + captured = capsys.readouterr() + assert rc == 0 + assert "Usage: synaptic-mcp" in captured.out + assert "synaptic-memory[mcp]" in captured.out + + +def test_mcp_version_uses_package_version(capsys: pytest.CaptureFixture[str]) -> None: + rc = mcp_cli.main(["--version"]) + + captured = capsys.readouterr() + assert rc == 0 + assert captured.out.strip().startswith("synaptic-mcp 0.")