synapse-framework 0.2.4__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 +207 -0
- synapse/app.py +942 -0
- synapse/cli.py +88 -0
- synapse/config.py +103 -0
- synapse/connectors/__init__.py +29 -0
- synapse/connectors/base.py +222 -0
- synapse/connectors/factory.py +202 -0
- synapse/connectors/gmail.py +292 -0
- synapse/connectors/mcp_wrapper.py +153 -0
- synapse/connectors/notion.py +161 -0
- synapse/connectors/photos.py +141 -0
- synapse/database/__init__.py +19 -0
- synapse/database/base.py +63 -0
- synapse/database/chroma.py +175 -0
- synapse/database/memory.py +132 -0
- synapse/database/pgvector.py +99 -0
- synapse/database/qdrant.py +138 -0
- synapse/embeddings/__init__.py +21 -0
- synapse/embeddings/base.py +262 -0
- synapse/export/__init__.py +14 -0
- synapse/export/mcp_exporter.py +202 -0
- synapse/graph/__init__.py +21 -0
- synapse/graph/base.py +130 -0
- synapse/graph/memory.py +20 -0
- synapse/graph/neo4j_store.py +176 -0
- synapse/graph/networkx_store.py +221 -0
- synapse/inspector/__init__.py +5 -0
- synapse/inspector/template.py +530 -0
- synapse/models/__init__.py +73 -0
- synapse/models/base.py +95 -0
- synapse/models/gemini.py +328 -0
- synapse/models/ollama.py +322 -0
- synapse/models/openai.py +289 -0
- synapse/oauth/__init__.py +7 -0
- synapse/oauth/google.py +194 -0
- synapse/oauth/notion.py +35 -0
- synapse/rag/__init__.py +8 -0
- synapse/rag/chunker.py +219 -0
- synapse/rag/hybrid_rag.py +122 -0
- synapse/resource/__init__.py +5 -0
- synapse/resource/resource.py +304 -0
- synapse/resources/__init__.py +5 -0
- synapse/skills/__init__.py +5 -0
- synapse/skills/skill.py +321 -0
- synapse/storage/__init__.py +24 -0
- synapse/storage/graph_exporter.py +624 -0
- synapse/storage/graph_store.py +162 -0
- synapse/storage/vector_store.py +103 -0
- synapse_framework-0.2.4.dist-info/METADATA +415 -0
- synapse_framework-0.2.4.dist-info/RECORD +54 -0
- synapse_framework-0.2.4.dist-info/WHEEL +5 -0
- synapse_framework-0.2.4.dist-info/entry_points.txt +2 -0
- synapse_framework-0.2.4.dist-info/licenses/LICENSE +21 -0
- synapse_framework-0.2.4.dist-info/top_level.txt +1 -0
synapse/rag/chunker.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Text chunking utilities for RAG ingestion in Synapse."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from langchain_core.documents import Document
|
|
9
|
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
|
10
|
+
|
|
11
|
+
from synapse.config import DEFAULT_CHUNK_OVERLAP, DEFAULT_CHUNK_SIZE
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DocumentChunker:
|
|
15
|
+
"""Splits arbitrary text and connector payloads into deterministic LangChain Document chunks."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
20
|
+
chunk_overlap: int = DEFAULT_CHUNK_OVERLAP,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Initializes the document chunker.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
chunk_size: Maximum character length per chunk. Defaults to 280.
|
|
26
|
+
chunk_overlap: Character overlap between consecutive chunks. Defaults to 30.
|
|
27
|
+
"""
|
|
28
|
+
self.chunk_size = chunk_size
|
|
29
|
+
self.chunk_overlap = chunk_overlap
|
|
30
|
+
self.splitter = RecursiveCharacterTextSplitter(
|
|
31
|
+
chunk_size=self.chunk_size,
|
|
32
|
+
chunk_overlap=self.chunk_overlap,
|
|
33
|
+
separators=["\n\n", "\n", ". ", "? ", "! ", " ", ""],
|
|
34
|
+
length_function=len,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def _generate_chunk_id(source: str, parent_id: str, index: int) -> str:
|
|
39
|
+
seed = f"{source}:{parent_id}:{index}"
|
|
40
|
+
return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
|
|
41
|
+
|
|
42
|
+
def chunk_text(
|
|
43
|
+
self,
|
|
44
|
+
text: str,
|
|
45
|
+
source: str = "text",
|
|
46
|
+
metadata: dict[str, Any] | None = None,
|
|
47
|
+
parent_id: str | None = None,
|
|
48
|
+
) -> list[Document]:
|
|
49
|
+
"""Splits raw text into indexed LangChain Document chunks."""
|
|
50
|
+
clean_text = text.strip()
|
|
51
|
+
if not clean_text:
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
base_meta = metadata.copy() if metadata else {}
|
|
55
|
+
base_meta["source"] = source
|
|
56
|
+
doc_id = (
|
|
57
|
+
parent_id
|
|
58
|
+
or hashlib.sha256(clean_text[:128].encode("utf-8")).hexdigest()[:12]
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
raw_chunks = self.splitter.split_text(clean_text)
|
|
62
|
+
documents: list[Document] = []
|
|
63
|
+
for idx, chunk in enumerate(raw_chunks):
|
|
64
|
+
chunk_meta = {
|
|
65
|
+
**base_meta,
|
|
66
|
+
"parent_id": doc_id,
|
|
67
|
+
"chunk_index": idx,
|
|
68
|
+
"chunk_id": self._generate_chunk_id(source, doc_id, idx),
|
|
69
|
+
}
|
|
70
|
+
documents.append(Document(page_content=chunk, metadata=chunk_meta))
|
|
71
|
+
|
|
72
|
+
return documents
|
|
73
|
+
|
|
74
|
+
def chunk_email(self, email_data: dict[str, Any]) -> list[Document]:
|
|
75
|
+
"""Formats and splits an email dictionary into Document chunks."""
|
|
76
|
+
email_id = str(email_data.get("id", ""))
|
|
77
|
+
subject = str(email_data.get("subject", "No Subject")).strip()
|
|
78
|
+
sender = str(
|
|
79
|
+
email_data.get("from") or email_data.get("sender") or "Unknown Sender"
|
|
80
|
+
).strip()
|
|
81
|
+
recipients = str(email_data.get("to", "Me")).strip()
|
|
82
|
+
date_str = str(email_data.get("date", "")).strip()
|
|
83
|
+
body = str(email_data.get("body", "")).strip()
|
|
84
|
+
is_unread = bool(email_data.get("is_unread", False))
|
|
85
|
+
read_tag = "UNREAD" if is_unread else "READ"
|
|
86
|
+
|
|
87
|
+
if not body and not subject:
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
header_context = (
|
|
91
|
+
f"[EMAIL | {read_tag}] Subject: {subject} | From: {sender} | To: {recipients}"
|
|
92
|
+
)
|
|
93
|
+
full_text = f"{header_context}\nDate: {date_str}\n\n{body}"
|
|
94
|
+
|
|
95
|
+
base_meta: dict[str, Any] = {
|
|
96
|
+
"source": "gmail",
|
|
97
|
+
"email_id": email_id,
|
|
98
|
+
"subject": subject,
|
|
99
|
+
"sender": sender,
|
|
100
|
+
"recipients": recipients,
|
|
101
|
+
"date": date_str,
|
|
102
|
+
"is_unread": is_unread,
|
|
103
|
+
"type": "Email",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
raw_chunks = self.splitter.split_text(full_text)
|
|
107
|
+
documents: list[Document] = []
|
|
108
|
+
for idx, chunk in enumerate(raw_chunks):
|
|
109
|
+
chunk_meta = {
|
|
110
|
+
**base_meta,
|
|
111
|
+
"chunk_index": idx,
|
|
112
|
+
"chunk_id": self._generate_chunk_id("gmail", email_id, idx),
|
|
113
|
+
}
|
|
114
|
+
documents.append(Document(page_content=chunk, metadata=chunk_meta))
|
|
115
|
+
|
|
116
|
+
return documents
|
|
117
|
+
|
|
118
|
+
def chunk_chat_turn(
|
|
119
|
+
self,
|
|
120
|
+
user_message: str,
|
|
121
|
+
assistant_response: str,
|
|
122
|
+
session_id: str,
|
|
123
|
+
turn_index: int,
|
|
124
|
+
) -> list[Document]:
|
|
125
|
+
"""Splits a single conversation turn into Document chunks."""
|
|
126
|
+
content = (
|
|
127
|
+
f"User: {user_message.strip()}\nAssistant: {assistant_response.strip()}"
|
|
128
|
+
)
|
|
129
|
+
base_meta: dict[str, Any] = {
|
|
130
|
+
"source": "chat_history",
|
|
131
|
+
"session_id": session_id,
|
|
132
|
+
"turn_index": turn_index,
|
|
133
|
+
"type": "Conversation",
|
|
134
|
+
}
|
|
135
|
+
return self.chunk_text(
|
|
136
|
+
text=content,
|
|
137
|
+
source="chat_history",
|
|
138
|
+
metadata=base_meta,
|
|
139
|
+
parent_id=f"{session_id}_{turn_index}",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def chunk_mcp_record(
|
|
143
|
+
self,
|
|
144
|
+
server_name: str,
|
|
145
|
+
tool_name: str,
|
|
146
|
+
record: dict[str, Any],
|
|
147
|
+
) -> list[Document]:
|
|
148
|
+
"""Normalizes and splits a record from any connector/MCP tool."""
|
|
149
|
+
if server_name == "gmail":
|
|
150
|
+
return self.chunk_email(record)
|
|
151
|
+
|
|
152
|
+
rec_id = str(
|
|
153
|
+
record.get("id")
|
|
154
|
+
or record.get("page_id")
|
|
155
|
+
or record.get("url")
|
|
156
|
+
or hashlib.sha256(str(record).encode("utf-8")).hexdigest()[:12]
|
|
157
|
+
)
|
|
158
|
+
title = str(
|
|
159
|
+
record.get("title")
|
|
160
|
+
or record.get("subject")
|
|
161
|
+
or record.get("name")
|
|
162
|
+
or record.get("summary")
|
|
163
|
+
or f"{server_name.upper()} Record"
|
|
164
|
+
).strip()
|
|
165
|
+
author = str(
|
|
166
|
+
record.get("sender")
|
|
167
|
+
or record.get("from")
|
|
168
|
+
or record.get("author")
|
|
169
|
+
or record.get("owner")
|
|
170
|
+
or record.get("creator")
|
|
171
|
+
or server_name
|
|
172
|
+
).strip()
|
|
173
|
+
date_str = str(
|
|
174
|
+
record.get("date")
|
|
175
|
+
or record.get("last_edited_time")
|
|
176
|
+
or record.get("created_at")
|
|
177
|
+
or record.get("updated_at")
|
|
178
|
+
or ""
|
|
179
|
+
).strip()
|
|
180
|
+
body = str(
|
|
181
|
+
record.get("body")
|
|
182
|
+
or record.get("content")
|
|
183
|
+
or record.get("text")
|
|
184
|
+
or record.get("description")
|
|
185
|
+
or record.get("snippet")
|
|
186
|
+
or ""
|
|
187
|
+
).strip()
|
|
188
|
+
url = str(record.get("url") or "").strip()
|
|
189
|
+
|
|
190
|
+
if not body and not title:
|
|
191
|
+
return []
|
|
192
|
+
|
|
193
|
+
header = f"[{server_name.upper()} | {tool_name}] Title: {title} | Source: {author}"
|
|
194
|
+
if url:
|
|
195
|
+
header += f" | URL: {url}"
|
|
196
|
+
full_text = f"{header}\nDate: {date_str}\n\n{body}".strip()
|
|
197
|
+
|
|
198
|
+
base_meta: dict[str, Any] = {
|
|
199
|
+
"source": server_name,
|
|
200
|
+
"tool": tool_name,
|
|
201
|
+
"record_id": rec_id,
|
|
202
|
+
"subject": title,
|
|
203
|
+
"sender": author,
|
|
204
|
+
"date": date_str,
|
|
205
|
+
"url": url,
|
|
206
|
+
"type": str(record.get("type") or server_name.capitalize()),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
raw_chunks = self.splitter.split_text(full_text)
|
|
210
|
+
documents: list[Document] = []
|
|
211
|
+
for idx, chunk in enumerate(raw_chunks):
|
|
212
|
+
chunk_meta = {
|
|
213
|
+
**base_meta,
|
|
214
|
+
"chunk_index": idx,
|
|
215
|
+
"chunk_id": self._generate_chunk_id(server_name, rec_id, idx),
|
|
216
|
+
}
|
|
217
|
+
documents.append(Document(page_content=chunk, metadata=chunk_meta))
|
|
218
|
+
|
|
219
|
+
return documents
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Hybrid vector and knowledge graph context retriever."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from langchain_core.documents import Document
|
|
9
|
+
|
|
10
|
+
from synapse.config import DEFAULT_TOP_K
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HybridRAGEngine:
|
|
14
|
+
"""Combines dense semantic similarity search with knowledge graph multi-hop relations."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
vector_store: Any | None = None,
|
|
19
|
+
graph_store: Any | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Initializes the Hybrid RAG engine."""
|
|
22
|
+
self.vector_store = vector_store
|
|
23
|
+
self.graph_store = graph_store
|
|
24
|
+
|
|
25
|
+
def _extract_candidate_entities(self, query: str) -> list[str]:
|
|
26
|
+
candidates = set()
|
|
27
|
+
if self.graph_store and hasattr(self.graph_store, "graph") and hasattr(self.graph_store.graph, "nodes"):
|
|
28
|
+
existing_nodes = list(self.graph_store.graph.nodes)
|
|
29
|
+
query_lower = query.lower()
|
|
30
|
+
for node in existing_nodes:
|
|
31
|
+
if str(node).lower() in query_lower:
|
|
32
|
+
candidates.add(str(node))
|
|
33
|
+
|
|
34
|
+
matches = re.findall(r"\b[A-Z][a-zA-Z0-9_-]+\b", query)
|
|
35
|
+
for match in matches:
|
|
36
|
+
candidates.add(match)
|
|
37
|
+
|
|
38
|
+
return list(candidates)
|
|
39
|
+
|
|
40
|
+
def retrieve_context(
|
|
41
|
+
self,
|
|
42
|
+
query: str,
|
|
43
|
+
k: int = DEFAULT_TOP_K,
|
|
44
|
+
filter_dict: dict[str, Any] | None = None,
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
"""Retrieves matching vector chunks and relational facts for a query."""
|
|
47
|
+
vector_docs: list[Document] = []
|
|
48
|
+
if self.vector_store is not None:
|
|
49
|
+
try:
|
|
50
|
+
vector_docs = self.vector_store.similarity_search(
|
|
51
|
+
query=query, k=k, filter_dict=filter_dict
|
|
52
|
+
)
|
|
53
|
+
except Exception:
|
|
54
|
+
vector_docs = []
|
|
55
|
+
|
|
56
|
+
candidate_entities: list[str] = []
|
|
57
|
+
graph_facts: list[str] = []
|
|
58
|
+
if self.graph_store is not None:
|
|
59
|
+
try:
|
|
60
|
+
candidate_entities = self._extract_candidate_entities(query)
|
|
61
|
+
for entity in candidate_entities:
|
|
62
|
+
facts = self.graph_store.find_relation_for(entity)
|
|
63
|
+
graph_facts.extend(facts)
|
|
64
|
+
except Exception:
|
|
65
|
+
pass # nosec B110
|
|
66
|
+
|
|
67
|
+
unique_facts = list(dict.fromkeys(graph_facts))
|
|
68
|
+
formatted_context = self._format_prompt_context(
|
|
69
|
+
vector_docs, unique_facts
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
serialized_chunks = [
|
|
73
|
+
{
|
|
74
|
+
"page_content": doc.page_content,
|
|
75
|
+
"metadata": doc.metadata,
|
|
76
|
+
"source": doc.metadata.get("source", "chroma_db"),
|
|
77
|
+
"subject": doc.metadata.get("subject", ""),
|
|
78
|
+
"chunk_id": doc.metadata.get("chunk_id", ""),
|
|
79
|
+
}
|
|
80
|
+
for doc in vector_docs
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
"query": query,
|
|
85
|
+
"vector_documents": vector_docs,
|
|
86
|
+
"chunks": serialized_chunks,
|
|
87
|
+
"graph_facts": unique_facts,
|
|
88
|
+
"graph_triples": unique_facts,
|
|
89
|
+
"entities_detected": candidate_entities,
|
|
90
|
+
"formatted_context": formatted_context,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _format_prompt_context(
|
|
95
|
+
vector_docs: list[Document],
|
|
96
|
+
graph_facts: list[str],
|
|
97
|
+
) -> str:
|
|
98
|
+
sections: list[str] = []
|
|
99
|
+
|
|
100
|
+
if graph_facts:
|
|
101
|
+
facts_block = "\n".join(f"- {fact}" for fact in graph_facts)
|
|
102
|
+
sections.append(
|
|
103
|
+
f"### RELATIONAL KNOWLEDGE GRAPH FACTS:\n{facts_block}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if vector_docs:
|
|
107
|
+
doc_chunks: list[str] = []
|
|
108
|
+
for idx, doc in enumerate(vector_docs, 1):
|
|
109
|
+
src = doc.metadata.get("source", "unknown")
|
|
110
|
+
subj = doc.metadata.get("subject", "")
|
|
111
|
+
header = f"[{src.upper()}" + (f" - {subj}" if subj else "") + "]"
|
|
112
|
+
doc_chunks.append(
|
|
113
|
+
f"{idx}. {header}\n{doc.page_content.strip()}"
|
|
114
|
+
)
|
|
115
|
+
sections.append(
|
|
116
|
+
"### RELEVANT DOCUMENT EXCERPTS:\n" + "\n\n".join(doc_chunks)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if not sections:
|
|
120
|
+
return "No relevant past context or graph facts found."
|
|
121
|
+
|
|
122
|
+
return "\n\n".join(sections)
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Resource management and document collection ingestion for Synapse."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from langchain_core.documents import Document
|
|
12
|
+
|
|
13
|
+
from synapse.rag.chunker import DocumentChunker
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Resource:
|
|
17
|
+
"""Manages collections of static or dynamic documents for Synapse RAG retrieval.
|
|
18
|
+
|
|
19
|
+
Supports ingesting raw strings, local files (.txt, .md, .pdf, .json, .csv),
|
|
20
|
+
directories, and LangChain Document instances. Automatically chunks and embeds
|
|
21
|
+
content for instant vector search and knowledge graph indexing.
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
```python
|
|
25
|
+
from synapse.resource import Resource
|
|
26
|
+
|
|
27
|
+
# Ingest files or text
|
|
28
|
+
res = Resource("knowledge_base")
|
|
29
|
+
res.add("README.md")
|
|
30
|
+
res.add("Quick note about architecture", title="Architecture")
|
|
31
|
+
res.add(["docs/guide.md", "data/faq.txt"])
|
|
32
|
+
|
|
33
|
+
# Pass to BaseApp:
|
|
34
|
+
app = BaseApp(resource=res)
|
|
35
|
+
```
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
name: str = "resource",
|
|
41
|
+
documents: Document | str | Path | Sequence[Any] | None = None,
|
|
42
|
+
chunker: DocumentChunker | None = None,
|
|
43
|
+
metadata: dict[str, Any] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Initializes a Resource container.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
name: Logical identifier for this resource collection.
|
|
49
|
+
documents: Optional initial document, file path, or sequence of documents.
|
|
50
|
+
chunker: Optional custom DocumentChunker instance.
|
|
51
|
+
metadata: Arbitrary metadata associated with this resource.
|
|
52
|
+
"""
|
|
53
|
+
self.name = name
|
|
54
|
+
self.chunker = chunker or DocumentChunker()
|
|
55
|
+
self.metadata = dict(metadata or {})
|
|
56
|
+
self._raw_documents: list[Document] = []
|
|
57
|
+
self._is_indexed: bool = False
|
|
58
|
+
|
|
59
|
+
if documents is not None:
|
|
60
|
+
self.add(documents)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def documents(self) -> list[Document]:
|
|
64
|
+
"""Returns the list of raw Document objects in this resource."""
|
|
65
|
+
return list(self._raw_documents)
|
|
66
|
+
|
|
67
|
+
def __len__(self) -> int:
|
|
68
|
+
return len(self._raw_documents)
|
|
69
|
+
|
|
70
|
+
def __repr__(self) -> str:
|
|
71
|
+
return f"<Resource name='{self.name}' documents={len(self._raw_documents)}>"
|
|
72
|
+
|
|
73
|
+
def add(
|
|
74
|
+
self,
|
|
75
|
+
*items: Any,
|
|
76
|
+
**extra_metadata: Any,
|
|
77
|
+
) -> Resource:
|
|
78
|
+
"""Adds one or more documents, text strings, files, or directories to this resource.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
*items: Variadic items to add. Each item can be a LangChain Document,
|
|
82
|
+
raw text string, file Path, directory Path, or a list/tuple of them.
|
|
83
|
+
**extra_metadata: Metadata merged into all items added during this call.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
The current Resource instance for fluent method chaining.
|
|
87
|
+
"""
|
|
88
|
+
for item in items:
|
|
89
|
+
self._ingest_item(item, extra_metadata)
|
|
90
|
+
self._is_indexed = False
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def _ingest_item(self, item: Any, extra_metadata: dict[str, Any]) -> None:
|
|
94
|
+
"""Internal dispatcher for handling diverse input types."""
|
|
95
|
+
if item is None:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if isinstance(item, (list, tuple, set)):
|
|
99
|
+
for sub_item in item:
|
|
100
|
+
self._ingest_item(sub_item, extra_metadata)
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
if isinstance(item, Document):
|
|
104
|
+
merged_meta = {**item.metadata, **extra_metadata, "resource": self.name}
|
|
105
|
+
self._raw_documents.append(
|
|
106
|
+
Document(page_content=item.page_content, metadata=merged_meta)
|
|
107
|
+
)
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
if isinstance(item, Path):
|
|
111
|
+
self._ingest_path(item, extra_metadata)
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
if isinstance(item, str):
|
|
115
|
+
clean_str = item.strip()
|
|
116
|
+
# Check if this string points to an existing file or directory
|
|
117
|
+
candidate = Path(clean_str)
|
|
118
|
+
if candidate.exists():
|
|
119
|
+
self._ingest_path(candidate, extra_metadata)
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
# Treat as raw text
|
|
123
|
+
doc_id = hashlib.sha256(clean_str[:256].encode("utf-8")).hexdigest()[:12]
|
|
124
|
+
meta = {
|
|
125
|
+
"source": "resource_text",
|
|
126
|
+
"resource": self.name,
|
|
127
|
+
"record_id": doc_id,
|
|
128
|
+
**extra_metadata,
|
|
129
|
+
}
|
|
130
|
+
self._raw_documents.append(
|
|
131
|
+
Document(page_content=clean_str, metadata=meta)
|
|
132
|
+
)
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
if isinstance(item, dict):
|
|
136
|
+
content = str(
|
|
137
|
+
item.get("content")
|
|
138
|
+
or item.get("text")
|
|
139
|
+
or item.get("body")
|
|
140
|
+
or item.get("page_content")
|
|
141
|
+
or json.dumps(item)
|
|
142
|
+
)
|
|
143
|
+
meta = {
|
|
144
|
+
"source": "resource_dict",
|
|
145
|
+
"resource": self.name,
|
|
146
|
+
**item.get("metadata", {}),
|
|
147
|
+
**extra_metadata,
|
|
148
|
+
}
|
|
149
|
+
self._raw_documents.append(Document(page_content=content, metadata=meta))
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
# Fallback for any other object: convert to string
|
|
153
|
+
self._raw_documents.append(
|
|
154
|
+
Document(
|
|
155
|
+
page_content=str(item),
|
|
156
|
+
metadata={"source": "resource_object", "resource": self.name, **extra_metadata},
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def _ingest_path(self, path: Path, extra_metadata: dict[str, Any]) -> None:
|
|
161
|
+
"""Reads a file or directory from disk."""
|
|
162
|
+
resolved = path.resolve()
|
|
163
|
+
if not resolved.exists():
|
|
164
|
+
raise FileNotFoundError(f"Resource path does not exist: {resolved}")
|
|
165
|
+
|
|
166
|
+
if resolved.is_dir():
|
|
167
|
+
# Ingest all supported files in directory
|
|
168
|
+
extensions = {".md", ".txt", ".json", ".csv", ".py", ".html", ".log", ".yaml", ".yml"}
|
|
169
|
+
for child in sorted(resolved.rglob("*")):
|
|
170
|
+
if child.is_file() and child.suffix.lower() in extensions:
|
|
171
|
+
self._ingest_file(child, extra_metadata)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
if resolved.is_file():
|
|
175
|
+
self._ingest_file(resolved, extra_metadata)
|
|
176
|
+
|
|
177
|
+
def _ingest_file(self, file_path: Path, extra_metadata: dict[str, Any]) -> None:
|
|
178
|
+
"""Reads a single file into a Document."""
|
|
179
|
+
suffix = file_path.suffix.lower()
|
|
180
|
+
title = file_path.stem.replace("_", " ").replace("-", " ").title()
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
184
|
+
except Exception as e:
|
|
185
|
+
content = f"Error reading file {file_path.name}: {e}"
|
|
186
|
+
|
|
187
|
+
meta = {
|
|
188
|
+
"source": str(file_path),
|
|
189
|
+
"filename": file_path.name,
|
|
190
|
+
"title": title,
|
|
191
|
+
"subject": title,
|
|
192
|
+
"resource": self.name,
|
|
193
|
+
"file_type": suffix.lstrip("."),
|
|
194
|
+
**extra_metadata,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
self._raw_documents.append(Document(page_content=content, metadata=meta))
|
|
198
|
+
|
|
199
|
+
def add_text(
|
|
200
|
+
self,
|
|
201
|
+
text: str,
|
|
202
|
+
title: str | None = None,
|
|
203
|
+
source: str = "text",
|
|
204
|
+
**metadata: Any,
|
|
205
|
+
) -> Resource:
|
|
206
|
+
"""Convenience helper to add raw text with a title."""
|
|
207
|
+
meta = {"source": source, **metadata}
|
|
208
|
+
if title:
|
|
209
|
+
meta["title"] = title
|
|
210
|
+
meta["subject"] = title
|
|
211
|
+
return self.add(text, **meta)
|
|
212
|
+
|
|
213
|
+
def add_file(
|
|
214
|
+
self,
|
|
215
|
+
file_path: str | Path,
|
|
216
|
+
**metadata: Any,
|
|
217
|
+
) -> Resource:
|
|
218
|
+
"""Convenience helper to add a single file."""
|
|
219
|
+
return self.add(Path(file_path), **metadata)
|
|
220
|
+
|
|
221
|
+
def add_directory(
|
|
222
|
+
self,
|
|
223
|
+
dir_path: str | Path,
|
|
224
|
+
**metadata: Any,
|
|
225
|
+
) -> Resource:
|
|
226
|
+
"""Convenience helper to ingest an entire directory."""
|
|
227
|
+
return self.add(Path(dir_path), **metadata)
|
|
228
|
+
|
|
229
|
+
def get_chunks(self) -> list[Document]:
|
|
230
|
+
"""Splits all ingested raw documents into indexable chunks using DocumentChunker."""
|
|
231
|
+
all_chunks: list[Document] = []
|
|
232
|
+
for doc in self._raw_documents:
|
|
233
|
+
src = doc.metadata.get("source", self.name)
|
|
234
|
+
rec_id = doc.metadata.get("record_id") or hashlib.sha256(
|
|
235
|
+
doc.page_content[:128].encode("utf-8")
|
|
236
|
+
).hexdigest()[:12]
|
|
237
|
+
chunks = self.chunker.chunk_text(
|
|
238
|
+
text=doc.page_content,
|
|
239
|
+
source=src,
|
|
240
|
+
metadata=doc.metadata,
|
|
241
|
+
parent_id=rec_id,
|
|
242
|
+
)
|
|
243
|
+
all_chunks.extend(chunks)
|
|
244
|
+
return all_chunks
|
|
245
|
+
|
|
246
|
+
def index_into(
|
|
247
|
+
self,
|
|
248
|
+
vector_store: Any | None = None,
|
|
249
|
+
graph_store: Any | None = None,
|
|
250
|
+
) -> int:
|
|
251
|
+
"""Chunks and indexes this resource into vector store and knowledge graph.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
vector_store: Optional vector store instance (LocalVectorStore, VectorStorage, BaseVectorDB).
|
|
255
|
+
graph_store: Optional graph store instance (PersistentGraphStore, BaseGraphStore).
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
The total number of chunks successfully processed.
|
|
259
|
+
"""
|
|
260
|
+
chunks = self.get_chunks()
|
|
261
|
+
if not chunks:
|
|
262
|
+
return 0
|
|
263
|
+
|
|
264
|
+
if vector_store is not None:
|
|
265
|
+
vector_store.add_documents(chunks)
|
|
266
|
+
|
|
267
|
+
if graph_store is not None:
|
|
268
|
+
# Register resource node and links in knowledge graph
|
|
269
|
+
graph_store.add_node_entity(
|
|
270
|
+
name=self.name,
|
|
271
|
+
entity_type="Resource",
|
|
272
|
+
attributes={"total_docs": len(self._raw_documents), "chunks": len(chunks)},
|
|
273
|
+
)
|
|
274
|
+
for doc in self._raw_documents:
|
|
275
|
+
title = (
|
|
276
|
+
doc.metadata.get("title")
|
|
277
|
+
or doc.metadata.get("filename")
|
|
278
|
+
or doc.metadata.get("subject")
|
|
279
|
+
or (doc.page_content[:35].strip() + "...")
|
|
280
|
+
)
|
|
281
|
+
if title:
|
|
282
|
+
graph_store.add_relation(
|
|
283
|
+
source=self.name,
|
|
284
|
+
relation="CONTAINS",
|
|
285
|
+
target=str(title),
|
|
286
|
+
source_type="Resource",
|
|
287
|
+
target_type="Document",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
self._is_indexed = True
|
|
291
|
+
return len(chunks)
|
|
292
|
+
|
|
293
|
+
def to_dict(self) -> dict[str, Any]:
|
|
294
|
+
"""Serializes resource statistics into a dictionary."""
|
|
295
|
+
return {
|
|
296
|
+
"name": self.name,
|
|
297
|
+
"document_count": len(self._raw_documents),
|
|
298
|
+
"chunk_count": len(self.get_chunks()),
|
|
299
|
+
"is_indexed": self._is_indexed,
|
|
300
|
+
"metadata": self.metadata,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
__all__ = ["Resource"]
|