preciso-graphrag 0.3.0rc1__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.
- config.py +477 -0
- core/backup.py +134 -0
- core/bootstrap.py +84 -0
- core/documents.py +444 -0
- core/export_adapters.py +392 -0
- core/file_io.py +43 -0
- core/local_embeddings.py +147 -0
- core/merge.py +548 -0
- core/query.py +1030 -0
- core/runtime_status.py +224 -0
- core/session_lock.py +74 -0
- core/storage/base.py +397 -0
- core/storage/graph_store.py +231 -0
- core/storage/kv_store.py +164 -0
- core/storage/shared_storage.py +116 -0
- core/storage/vector_store.py +285 -0
- core/storage/vector_write_batch.py +45 -0
- core/summary.py +153 -0
- core/summary_maintenance.py +335 -0
- core/transactions.py +182 -0
- core/utils.py +663 -0
- ingest/_materialize.py +418 -0
- ingest/file_access.py +39 -0
- ingest/parser.py +53 -0
- ingest/pipeline.py +8 -0
- ingest/reconciler.py +250 -0
- ingest/transformer.py +77 -0
- ingest/validator.py +161 -0
- preciso_graphrag-0.3.0rc1.dist-info/METADATA +601 -0
- preciso_graphrag-0.3.0rc1.dist-info/RECORD +48 -0
- preciso_graphrag-0.3.0rc1.dist-info/WHEEL +5 -0
- preciso_graphrag-0.3.0rc1.dist-info/entry_points.txt +3 -0
- preciso_graphrag-0.3.0rc1.dist-info/licenses/LICENSE +201 -0
- preciso_graphrag-0.3.0rc1.dist-info/top_level.txt +4 -0
- preciso_mcp/__init__.py +0 -0
- preciso_mcp/backup_cli.py +31 -0
- preciso_mcp/cli.py +63 -0
- preciso_mcp/server.py +610 -0
- preciso_mcp/setup_cli.py +299 -0
- preciso_mcp/tools/__init__.py +0 -0
- preciso_mcp/tools/checkpoint_tool.py +36 -0
- preciso_mcp/tools/export_tool.py +41 -0
- preciso_mcp/tools/ingest_from_file_tool.py +178 -0
- preciso_mcp/tools/pending_summaries_tool.py +8 -0
- preciso_mcp/tools/query_tool.py +35 -0
- preciso_mcp/tools/reconcile_tool.py +206 -0
- preciso_mcp/tools/status_tool.py +11 -0
- preciso_mcp/user_settings.py +102 -0
config.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import asyncio
|
|
5
|
+
import importlib
|
|
6
|
+
import logging
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
# Same logger core/utils.py configures handlers on; config.py must not import
|
|
14
|
+
# core.utils (core.utils imports config).
|
|
15
|
+
logger = logging.getLogger("graphrag_mcp")
|
|
16
|
+
|
|
17
|
+
# ==========================================================================
|
|
18
|
+
# TOKEN LIMIT DEFAULTS (used by summary.py, merge.py, query.py)
|
|
19
|
+
# Prevents LLM calls from exceeding API token limits
|
|
20
|
+
# ==========================================================================
|
|
21
|
+
DEFAULT_MAX_ENTITY_TOKENS = int(os.getenv("GRAPHRAG_MAX_ENTITY_TOKENS", "4000"))
|
|
22
|
+
DEFAULT_MAX_RELATION_TOKENS = int(os.getenv("GRAPHRAG_MAX_RELATION_TOKENS", "4000"))
|
|
23
|
+
DEFAULT_MAX_TOTAL_TOKENS = int(os.getenv("GRAPHRAG_MAX_TOTAL_TOKENS", "16000"))
|
|
24
|
+
DEFAULT_KG_EVIDENCE_TOP_K = int(os.getenv("GRAPHRAG_KG_EVIDENCE_TOP_K", "8"))
|
|
25
|
+
DEFAULT_KG_EVIDENCE_MIN_SIMILARITY = float(
|
|
26
|
+
os.getenv("GRAPHRAG_KG_EVIDENCE_MIN_SIMILARITY", "0.35")
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# ============================================================================
|
|
30
|
+
# KNOWLEDGE GRAPH DEFAULTS (used by merge.py, query.py)
|
|
31
|
+
# Controls entity/relationship merging and retrieval behavior
|
|
32
|
+
# ============================================================================
|
|
33
|
+
DEFAULT_MAX_FILE_PATHS = 8 # Max source file paths to store per entity/relation
|
|
34
|
+
DEFAULT_FILE_PATH_MORE_PLACEHOLDER = "more_paths" # Placeholder when truncating file paths
|
|
35
|
+
DEFAULT_ENTITY_NAME_MAX_LENGTH = 255 # Max entity name length
|
|
36
|
+
DEFAULT_TOP_K = 5 # Top K results to return in queries
|
|
37
|
+
DEFAULT_CHUNK_TOP_K = 20 # Top K chunks to retrieve
|
|
38
|
+
GRAPH_FIELD_SEP = "<SEP>" # Internal separator for multi-value graph fields
|
|
39
|
+
|
|
40
|
+
# ============================================================================
|
|
41
|
+
# MERGING & SOURCE TRACKING DEFAULTS (used by merge.py)
|
|
42
|
+
# Controls how entities/relationships are merged when re-ingested
|
|
43
|
+
# ============================================================================
|
|
44
|
+
DEFAULT_SOURCE_IDS_LIMIT_METHOD = "KEEP" # How to limit stored source references: "KEEP" (keep old) or "FIFO" (newest)
|
|
45
|
+
SOURCE_IDS_LIMIT_METHOD_KEEP = "KEEP" # Keep older source IDs when limit reached
|
|
46
|
+
SOURCE_IDS_LIMIT_METHOD_FIFO = "FIFO" # Use FIFO (first-in-first-out) when limit reached
|
|
47
|
+
VALID_SOURCE_IDS_LIMIT_METHODS = {
|
|
48
|
+
SOURCE_IDS_LIMIT_METHOD_KEEP,
|
|
49
|
+
SOURCE_IDS_LIMIT_METHOD_FIFO,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# ==========================================================================
|
|
53
|
+
# QUERY DEFAULTS (used by query.py)
|
|
54
|
+
# ==========================================================================
|
|
55
|
+
DEFAULT_HISTORY_TURNS = int(os.getenv("GRAPHRAG_HISTORY_TURNS", "3"))
|
|
56
|
+
|
|
57
|
+
# ==========================================================================
|
|
58
|
+
# SUMMARY DEFAULTS (used by summary.py, merge.py)
|
|
59
|
+
# Preciso never calls an LLM to compress descriptions — compression is always
|
|
60
|
+
# deferred to the MCP-driving agent (preciso_mcp/tools/pending_summaries_tool.py).
|
|
61
|
+
# ==========================================================================
|
|
62
|
+
DEFAULT_SUMMARY_CONTEXT_TOKENS = int(os.getenv("GRAPHRAG_SUMMARY_CONTEXT_TOKENS", "8000"))
|
|
63
|
+
DEFAULT_SUMMARY_MAX_TOKENS = int(os.getenv("GRAPHRAG_SUMMARY_MAX_TOKENS", "1024"))
|
|
64
|
+
# Two-zone description merge (core/summary.py): descriptions are stored as ONE
|
|
65
|
+
# marker-tagged rolling summary of old mentions + a verbatim tail of the most recent
|
|
66
|
+
# raw_tail_size descriptions. Compression fires only when the tail exceeds this count
|
|
67
|
+
# or the field exceeds summary_context_size — never on a bare description count.
|
|
68
|
+
# GRAPHRAG_FORCE_LLM_SUMMARY_ON_MERGE is honored as a legacy alias for the tail size.
|
|
69
|
+
DEFAULT_RAW_TAIL_SIZE = int(
|
|
70
|
+
os.getenv(
|
|
71
|
+
"GRAPHRAG_RAW_TAIL_SIZE",
|
|
72
|
+
os.getenv("GRAPHRAG_FORCE_LLM_SUMMARY_ON_MERGE", "8"),
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
# Sentinel prefix identifying the single rolling-summary segment inside a SEP-joined
|
|
76
|
+
# description field. Deliberately NOT env-configurable: changing it would orphan
|
|
77
|
+
# markers already persisted in GRAPH_IS_HERE/. ASCII on purpose (tokenizer/grep-safe).
|
|
78
|
+
#
|
|
79
|
+
# CONTRACT — "storage keeps it, every exit strips it":
|
|
80
|
+
# The marker exists ONLY inside GRAPH_IS_HERE/ (graph nodes/edges, incl. the
|
|
81
|
+
# graphml artifact) so that the next merge can find the rolling-summary segment;
|
|
82
|
+
# copying GRAPH_IS_HERE/ to another machine must preserve it. Every surface that
|
|
83
|
+
# leaves storage must pass through core.utils.strip_summary_marker:
|
|
84
|
+
# - embedding content core/merge.py (entity_content / rel_content)
|
|
85
|
+
# - relationship VDB payload core/merge.py ("description" field)
|
|
86
|
+
# - LLM query context core/query.py (_apply_token_truncation)
|
|
87
|
+
# - user-facing query results core/utils.py (convert_to_user_format)
|
|
88
|
+
# - Neo4j export properties core/export_adapters.py (_node/_edge_properties)
|
|
89
|
+
# - Qdrant export payloads core/export_adapters.py (_vector_payload)
|
|
90
|
+
# - agent-summary tool outputs preciso_mcp/tools/pending_summaries_tool.py
|
|
91
|
+
# (list_pending_summaries' content_to_summarize
|
|
92
|
+
# is read from raw_tail — never marker-tagged
|
|
93
|
+
# by construction; submit_summary re-embeds via
|
|
94
|
+
# the same entity_content/rel_content path above)
|
|
95
|
+
# Adding a new export/output surface? Strip there too, and extend the guard test
|
|
96
|
+
# in tests/manual/marker_leak_manual.py (and tests/test_marker_leak.py).
|
|
97
|
+
SUMMARY_MARKER = "<<SUM>>"
|
|
98
|
+
|
|
99
|
+
# ============================================================================
|
|
100
|
+
# EMBEDDING DEFAULTS
|
|
101
|
+
# ============================================================================
|
|
102
|
+
DEFAULT_EMBEDDING_PROVIDER = os.getenv("GRAPHRAG_EMBEDDING_PROVIDER", "ollama")
|
|
103
|
+
DEFAULT_EMBEDDING_MODEL = os.getenv("GRAPHRAG_EMBEDDING_MODEL", "mxbai-embed-large")
|
|
104
|
+
DEFAULT_EMBEDDING_DIM = int(os.getenv("GRAPHRAG_EMBEDDING_DIM", "768"))
|
|
105
|
+
DEFAULT_EMBEDDING_MAX_TOKENS = int(os.getenv("GRAPHRAG_EMBEDDING_MAX_TOKENS", "8192"))
|
|
106
|
+
DEFAULT_OPENAI_EMBEDDING_MODEL = os.getenv("GRAPHRAG_OPENAI_EMBEDDING_MODEL", "text-embedding-3-large")
|
|
107
|
+
DEFAULT_COHERE_EMBEDDING_MODEL = os.getenv("GRAPHRAG_COHERE_EMBEDDING_MODEL", "embed-english-v3.0")
|
|
108
|
+
|
|
109
|
+
# ============================================================================
|
|
110
|
+
# DOWNSTREAM EXPORT DEFAULTS
|
|
111
|
+
# Optional settings for Neo4j and Qdrant exports.
|
|
112
|
+
# These are read from environment variables so exports stay config-driven.
|
|
113
|
+
# ============================================================================
|
|
114
|
+
DEFAULT_NEO4J_URI = os.getenv("GRAPHRAG_NEO4J_URI", "")
|
|
115
|
+
DEFAULT_NEO4J_USERNAME = os.getenv("GRAPHRAG_NEO4J_USERNAME", "neo4j")
|
|
116
|
+
DEFAULT_NEO4J_PASSWORD = os.getenv("GRAPHRAG_NEO4J_PASSWORD", "")
|
|
117
|
+
DEFAULT_NEO4J_DATABASE = os.getenv("GRAPHRAG_NEO4J_DATABASE", "neo4j")
|
|
118
|
+
|
|
119
|
+
DEFAULT_QDRANT_URL = os.getenv("GRAPHRAG_QDRANT_URL", "")
|
|
120
|
+
DEFAULT_QDRANT_API_KEY = os.getenv("GRAPHRAG_QDRANT_API_KEY", "")
|
|
121
|
+
DEFAULT_QDRANT_COLLECTION_PREFIX = os.getenv("GRAPHRAG_QDRANT_COLLECTION_PREFIX", "preciso")
|
|
122
|
+
|
|
123
|
+
DEFAULT_EXPORT_WORKSPACE = os.getenv("GRAPHRAG_EXPORT_WORKSPACE", "default")
|
|
124
|
+
DEFAULT_EXPORT_CLEAR_EXISTING = os.getenv("GRAPHRAG_EXPORT_CLEAR_EXISTING", "false").lower() == "true"
|
|
125
|
+
|
|
126
|
+
# ============================================================================
|
|
127
|
+
# QUERY HEURISTIC DEFAULTS (used by query.py)
|
|
128
|
+
# Domain heuristics live here (not in core/) so non-financial deployments can
|
|
129
|
+
# override them without touching code.
|
|
130
|
+
# ============================================================================
|
|
131
|
+
# Phrases signalling a two-period comparison query ("hybrid" mode auto-upgrades
|
|
132
|
+
# to "global" when one matches). Override with GRAPHRAG_YOY_SIGNAL_PHRASES as a
|
|
133
|
+
# comma-separated list; items are matched verbatim against the lowercased query,
|
|
134
|
+
# so leading/trailing spaces are significant (e.g. " vs ").
|
|
135
|
+
_DEFAULT_YOY_SIGNAL_PHRASES = [
|
|
136
|
+
"change from",
|
|
137
|
+
"changed from",
|
|
138
|
+
"compare",
|
|
139
|
+
"comparison",
|
|
140
|
+
"growth from",
|
|
141
|
+
"grew from",
|
|
142
|
+
"increase from",
|
|
143
|
+
"increased from",
|
|
144
|
+
"decrease from",
|
|
145
|
+
"decreased from",
|
|
146
|
+
"year over year",
|
|
147
|
+
"yoy",
|
|
148
|
+
"from fiscal",
|
|
149
|
+
"from fy",
|
|
150
|
+
"versus",
|
|
151
|
+
" vs ",
|
|
152
|
+
"compared to",
|
|
153
|
+
"relative to",
|
|
154
|
+
]
|
|
155
|
+
_yoy_phrases_env = os.getenv("GRAPHRAG_YOY_SIGNAL_PHRASES")
|
|
156
|
+
DEFAULT_YOY_SIGNAL_PHRASES = (
|
|
157
|
+
[phrase.lower() for phrase in _yoy_phrases_env.split(",") if phrase]
|
|
158
|
+
if _yoy_phrases_env is not None
|
|
159
|
+
else _DEFAULT_YOY_SIGNAL_PHRASES
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Persona line opening the default rag_response prompt. GRAPHRAG_RAG_PERSONA
|
|
163
|
+
# accepts "neutral" (default), "financial" (the original Preciso wording), or
|
|
164
|
+
# any custom persona sentence used verbatim (avoid `{`/`}` — the assembled
|
|
165
|
+
# prompt goes through str.format).
|
|
166
|
+
_RAG_PERSONAS = {
|
|
167
|
+
"neutral": "You are a knowledge graph retrieval assistant.",
|
|
168
|
+
"financial": "You are a financial graph retrieval assistant.",
|
|
169
|
+
}
|
|
170
|
+
_rag_persona_env = os.getenv("GRAPHRAG_RAG_PERSONA", "neutral")
|
|
171
|
+
DEFAULT_RAG_PERSONA = _RAG_PERSONAS.get(_rag_persona_env.strip().lower(), _rag_persona_env)
|
|
172
|
+
|
|
173
|
+
# ============================================================================
|
|
174
|
+
# LLM PROMPTS (used by query.py)
|
|
175
|
+
# Templates sent to LLM functions for response generation
|
|
176
|
+
# ============================================================================
|
|
177
|
+
PROMPTS = {
|
|
178
|
+
"rag_response": (
|
|
179
|
+
# Used by query.py → kg_query()
|
|
180
|
+
# Final prompt to generate response from knowledge graph context
|
|
181
|
+
DEFAULT_RAG_PERSONA + "\n"
|
|
182
|
+
"Use the provided context to answer in {response_type}.\n"
|
|
183
|
+
"Additional user guidance: {user_prompt}\n\n"
|
|
184
|
+
"{context_data}"
|
|
185
|
+
),
|
|
186
|
+
"kg_query_context": (
|
|
187
|
+
# Used by query.py → to format context before sending to LLM
|
|
188
|
+
"-----Entities-----\n{entities_str}\n\n"
|
|
189
|
+
"-----Relationships-----\n{relations_str}\n\n"
|
|
190
|
+
"-----Text Chunks-----\n{text_chunks_str}\n\n"
|
|
191
|
+
"-----References-----\n{reference_list_str}"
|
|
192
|
+
),
|
|
193
|
+
"fail_response": "No relevant graph context could be constructed for the query.",
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def _fallback_embed(texts, **kwargs):
|
|
198
|
+
"""Return deterministic lexical embeddings for offline/test operation.
|
|
199
|
+
|
|
200
|
+
This is intentionally small and dependency-free, but unlike the former
|
|
201
|
+
all-zero placeholder it preserves token overlap so cosine search remains
|
|
202
|
+
functional when no external embedding provider is configured.
|
|
203
|
+
"""
|
|
204
|
+
if not isinstance(texts, list):
|
|
205
|
+
texts = [texts]
|
|
206
|
+
|
|
207
|
+
embeddings: list[list[float]] = []
|
|
208
|
+
for text in texts:
|
|
209
|
+
vector = [0.0] * 8
|
|
210
|
+
tokens = re.findall(r"[a-z0-9]+", str(text).lower())
|
|
211
|
+
for token in tokens:
|
|
212
|
+
digest = hashlib.blake2b(token.encode("utf-8"), digest_size=1).digest()
|
|
213
|
+
index = digest[0] % len(vector)
|
|
214
|
+
vector[index] += 1.0
|
|
215
|
+
norm = math.sqrt(sum(value * value for value in vector))
|
|
216
|
+
if norm:
|
|
217
|
+
vector = [value / norm for value in vector]
|
|
218
|
+
embeddings.append(vector)
|
|
219
|
+
return embeddings
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def _ollama_embed(texts: list[str], **kwargs) -> list[list[float]]:
|
|
223
|
+
ollama = importlib.import_module("ollama")
|
|
224
|
+
model = kwargs.get("model") or kwargs.get("model_name") or DEFAULT_EMBEDDING_MODEL
|
|
225
|
+
if not isinstance(texts, list):
|
|
226
|
+
texts = [texts]
|
|
227
|
+
host = os.getenv("OLLAMA_BASE_URL") or os.getenv("OLLAMA_HOST")
|
|
228
|
+
if os.getenv("GRAPHRAG_OLLAMA_TASK_PREFIXES") == "v1":
|
|
229
|
+
if model.startswith("nomic-embed-text"):
|
|
230
|
+
prefix = "search_query: " if kwargs.get("context") == "query" else "search_document: "
|
|
231
|
+
texts = [prefix + text for text in texts]
|
|
232
|
+
elif model.startswith("mxbai-embed-large") and kwargs.get("context") == "query":
|
|
233
|
+
texts = ["Represent this sentence for searching relevant passages: " + text for text in texts]
|
|
234
|
+
client = ollama.AsyncClient(**({"host": host} if host else {}))
|
|
235
|
+
try:
|
|
236
|
+
if hasattr(client, "embed"):
|
|
237
|
+
response = await client.embed(model=model, input=texts)
|
|
238
|
+
return response["embeddings"]
|
|
239
|
+
# Older clients expose only the single-text endpoint.
|
|
240
|
+
embeddings: list[list[float]] = []
|
|
241
|
+
for text in texts:
|
|
242
|
+
response = await client.embeddings(model=model, prompt=text)
|
|
243
|
+
embeddings.append(response["embedding"])
|
|
244
|
+
return embeddings
|
|
245
|
+
finally:
|
|
246
|
+
if hasattr(client, "close"):
|
|
247
|
+
await client.close()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def _openai_embed(texts: list[str], **kwargs) -> list[list[float]]:
|
|
251
|
+
try:
|
|
252
|
+
from openai import AsyncOpenAI
|
|
253
|
+
except Exception as exc:
|
|
254
|
+
raise ImportError("OpenAI SDK not installed. Run: pip install openai") from exc
|
|
255
|
+
api_key = os.getenv("GRAPHRAG_OPENAI_API_KEY") or os.getenv("OPENAI_API_KEY")
|
|
256
|
+
if not api_key:
|
|
257
|
+
raise ValueError("OpenAI API key missing. Set GRAPHRAG_OPENAI_API_KEY or OPENAI_API_KEY.")
|
|
258
|
+
model = kwargs.get("model") or kwargs.get("model_name") or DEFAULT_OPENAI_EMBEDDING_MODEL
|
|
259
|
+
if not isinstance(texts, list):
|
|
260
|
+
texts = [texts]
|
|
261
|
+
request = {"model": model, "input": texts}
|
|
262
|
+
if model.startswith("text-embedding-3"):
|
|
263
|
+
request["dimensions"] = DEFAULT_EMBEDDING_DIM
|
|
264
|
+
async with AsyncOpenAI(api_key=api_key) as client:
|
|
265
|
+
response = await client.embeddings.create(**request)
|
|
266
|
+
return [item.embedding for item in response.data]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
async def _cohere_embed(texts: list[str], **kwargs) -> list[list[float]]:
|
|
270
|
+
try:
|
|
271
|
+
import cohere
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
raise ImportError("Cohere SDK not installed. Run: pip install cohere") from exc
|
|
274
|
+
api_key = os.getenv("GRAPHRAG_COHERE_API_KEY") or os.getenv("COHERE_API_KEY")
|
|
275
|
+
if not api_key:
|
|
276
|
+
raise ValueError("Cohere API key missing. Set GRAPHRAG_COHERE_API_KEY or COHERE_API_KEY.")
|
|
277
|
+
model = kwargs.get("model") or kwargs.get("model_name") or DEFAULT_COHERE_EMBEDDING_MODEL
|
|
278
|
+
if not isinstance(texts, list):
|
|
279
|
+
texts = [texts]
|
|
280
|
+
input_type = "search_query" if kwargs.get("context") == "query" else "search_document"
|
|
281
|
+
async with cohere.AsyncClient(api_key) as client:
|
|
282
|
+
response = await client.embed(texts=texts, model=model, input_type=input_type)
|
|
283
|
+
embeddings = response.get("embeddings") if isinstance(response, dict) else response.embeddings
|
|
284
|
+
return list(embeddings or [])
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def build_default_embedding_func(*, probe: bool = True) -> Any:
|
|
288
|
+
"""Build the EmbeddingFunc for the configured provider.
|
|
289
|
+
|
|
290
|
+
For the ollama provider this performs a NETWORK round-trip to probe the
|
|
291
|
+
model's embedding dimension — call it from server startup (or a script's
|
|
292
|
+
main path), never at module import.
|
|
293
|
+
"""
|
|
294
|
+
from core.storage.base import EmbeddingFunc
|
|
295
|
+
|
|
296
|
+
provider = DEFAULT_EMBEDDING_PROVIDER.lower()
|
|
297
|
+
if provider == "fastembed":
|
|
298
|
+
from core.local_embeddings import build_embedding
|
|
299
|
+
directory = os.getenv("GRAPHRAG_LOCAL_MODEL_DIR")
|
|
300
|
+
return build_embedding(Path(directory) if directory else None)
|
|
301
|
+
if provider == "ollama":
|
|
302
|
+
# Try to auto-detect embedding dimension from the Ollama model.
|
|
303
|
+
# If detection fails, fall back to the configured default.
|
|
304
|
+
embedding = EmbeddingFunc(
|
|
305
|
+
embedding_dim=DEFAULT_EMBEDDING_DIM,
|
|
306
|
+
max_token_size=DEFAULT_EMBEDDING_MAX_TOKENS,
|
|
307
|
+
func=_ollama_embed,
|
|
308
|
+
model_name=DEFAULT_EMBEDDING_MODEL,
|
|
309
|
+
provider="ollama",
|
|
310
|
+
supports_asymmetric=os.getenv("GRAPHRAG_OLLAMA_TASK_PREFIXES") == "v1",
|
|
311
|
+
model_revision=os.getenv("GRAPHRAG_EMBEDDING_REVISION") or None,
|
|
312
|
+
)
|
|
313
|
+
if probe:
|
|
314
|
+
try:
|
|
315
|
+
asyncio.get_running_loop()
|
|
316
|
+
except RuntimeError:
|
|
317
|
+
asyncio.run(probe_ollama_embedding(embedding))
|
|
318
|
+
else:
|
|
319
|
+
# Compatibility for synchronous scripts called inside a loop.
|
|
320
|
+
# The server uses the async probe directly, without a thread.
|
|
321
|
+
import concurrent.futures
|
|
322
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
323
|
+
pool.submit(lambda: asyncio.run(probe_ollama_embedding(embedding))).result()
|
|
324
|
+
return embedding
|
|
325
|
+
if provider == "openai":
|
|
326
|
+
return EmbeddingFunc(
|
|
327
|
+
embedding_dim=DEFAULT_EMBEDDING_DIM,
|
|
328
|
+
max_token_size=DEFAULT_EMBEDDING_MAX_TOKENS,
|
|
329
|
+
func=_openai_embed,
|
|
330
|
+
model_name=DEFAULT_OPENAI_EMBEDDING_MODEL,
|
|
331
|
+
provider="openai",
|
|
332
|
+
model_revision=os.getenv("GRAPHRAG_EMBEDDING_REVISION") or None,
|
|
333
|
+
)
|
|
334
|
+
if provider == "cohere":
|
|
335
|
+
return EmbeddingFunc(
|
|
336
|
+
embedding_dim=DEFAULT_EMBEDDING_DIM,
|
|
337
|
+
max_token_size=DEFAULT_EMBEDDING_MAX_TOKENS,
|
|
338
|
+
func=_cohere_embed,
|
|
339
|
+
model_name=DEFAULT_COHERE_EMBEDDING_MODEL,
|
|
340
|
+
provider="cohere",
|
|
341
|
+
supports_asymmetric=True,
|
|
342
|
+
model_revision=os.getenv("GRAPHRAG_EMBEDDING_REVISION") or None,
|
|
343
|
+
)
|
|
344
|
+
if provider == "anthropic":
|
|
345
|
+
raise ValueError("Anthropic does not provide embeddings. Use openai, cohere, or ollama.")
|
|
346
|
+
if provider == "fallback":
|
|
347
|
+
return EmbeddingFunc(
|
|
348
|
+
embedding_dim=8,
|
|
349
|
+
max_token_size=DEFAULT_EMBEDDING_MAX_TOKENS,
|
|
350
|
+
func=_fallback_embed,
|
|
351
|
+
model_name="fallback",
|
|
352
|
+
provider="fallback",
|
|
353
|
+
model_revision="lexical-v1",
|
|
354
|
+
)
|
|
355
|
+
raise ValueError(f"Unsupported embedding provider: {DEFAULT_EMBEDDING_PROVIDER}")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
async def probe_ollama_embedding(embedding: Any) -> None:
|
|
359
|
+
timeout = float(os.getenv("GRAPHRAG_EMBEDDING_PROBE_TIMEOUT", "15"))
|
|
360
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
361
|
+
raise ValueError("embedding probe timeout must be finite and greater than zero")
|
|
362
|
+
try:
|
|
363
|
+
async with asyncio.timeout(timeout):
|
|
364
|
+
vectors = await _ollama_embed(["test"], model=embedding.model_name)
|
|
365
|
+
if not isinstance(vectors, list) or len(vectors) != 1 or not vectors[0]:
|
|
366
|
+
raise ValueError("Ollama embedding probe returned no vectors")
|
|
367
|
+
if not all(math.isfinite(float(value)) for value in vectors[0]):
|
|
368
|
+
raise ValueError("Ollama embedding probe returned non-finite vectors")
|
|
369
|
+
embedding.embedding_dim = len(vectors[0])
|
|
370
|
+
embedding.initialization_error = None
|
|
371
|
+
except Exception as exc:
|
|
372
|
+
embedding.initialization_error = f"Ollama embedding probe failed for model {embedding.model_name}: {type(exc).__name__}: {exc}"
|
|
373
|
+
logger.warning("%s; using configured dimension %d", embedding.initialization_error, embedding.embedding_dim)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def build_global_config(
|
|
378
|
+
*,
|
|
379
|
+
working_dir: str | None = None,
|
|
380
|
+
llm_model_func: Any = None, # LLM function to call: can be OpenAI, Claude, local model, or None (no LLM)
|
|
381
|
+
embedding_func: Any = None, # Embedding function: converts text to vectors for similarity search
|
|
382
|
+
tokenizer: Any = None, # Token counter: counts tokens in text (uses tiktoken or fallback)
|
|
383
|
+
extra: dict[str, Any] | None = None, # Additional custom config values
|
|
384
|
+
) -> dict[str, Any]:
|
|
385
|
+
"""
|
|
386
|
+
Build a global configuration dictionary used throughout the system.
|
|
387
|
+
|
|
388
|
+
This function creates a single config object passed to ALL functions that need settings.
|
|
389
|
+
Instead of hardcoding values in each file, all functions read from this global_config dict.
|
|
390
|
+
"""
|
|
391
|
+
base_dir = Path(working_dir or os.getenv("GRAPHRAG_MCP_WORKDIR", "./graphrag_mcp_data"))
|
|
392
|
+
config: dict[str, Any] = {
|
|
393
|
+
# ====================================================================
|
|
394
|
+
# CORE PLUGGABLE FUNCTIONS (can be None or custom implementations)
|
|
395
|
+
# ====================================================================
|
|
396
|
+
"working_dir": str(base_dir), # Where to store graph/vector DB data
|
|
397
|
+
"input_dir": os.getenv("GRAPHRAG_INPUT_DIR", "extractions"),
|
|
398
|
+
"reconciliation_dir": os.getenv("GRAPHRAG_RECONCILIATION_DIR", str(base_dir / "reconciled")),
|
|
399
|
+
"allow_partial_ingest": os.getenv("GRAPHRAG_ALLOW_PARTIAL_INGEST", "false").lower() == "true",
|
|
400
|
+
"llm_model_func": llm_model_func, # LLM to use (defaults to None = no LLM)
|
|
401
|
+
"embedding_func": embedding_func, # Embedding model (converts text→vectors)
|
|
402
|
+
"tokenizer": tokenizer, # Token counter (GPT-4o-mini or fallback)
|
|
403
|
+
|
|
404
|
+
# ====================================================================
|
|
405
|
+
# VECTOR EMBEDDING SETTINGS (used by storage/ and query.py)
|
|
406
|
+
# Controls how text is converted to vectors for similarity search
|
|
407
|
+
# ====================================================================
|
|
408
|
+
"embedding_batch_num": 8, # Batch size for embedding operations
|
|
409
|
+
"vector_db_storage_cls_kwargs": {
|
|
410
|
+
"cosine_better_than_threshold": 0.6, # Min similarity threshold for results
|
|
411
|
+
},
|
|
412
|
+
|
|
413
|
+
# ====================================================================
|
|
414
|
+
# LLM CACHING SETTINGS (used by utils.py and summary.py)
|
|
415
|
+
# Caches LLM responses to avoid redundant API calls
|
|
416
|
+
# ====================================================================
|
|
417
|
+
"enable_llm_cache": True, # Cache query/summarization results
|
|
418
|
+
"enable_llm_cache_for_entity_extract": True, # Cache extraction-time LLM calls
|
|
419
|
+
|
|
420
|
+
# ====================================================================
|
|
421
|
+
# SUMMARY & DESCRIPTION SETTINGS (used by summary.py, merge.py)
|
|
422
|
+
# Controls how entity/relationship descriptions are merged. Compression
|
|
423
|
+
# itself is always deferred to the MCP-driving agent — see SUMMARY_MARKER
|
|
424
|
+
# comment above and preciso_mcp/tools/pending_summaries_tool.py.
|
|
425
|
+
# ====================================================================
|
|
426
|
+
"summary_context_size": DEFAULT_SUMMARY_CONTEXT_TOKENS, # Field size (tokens) that triggers compression
|
|
427
|
+
"summary_max_tokens": DEFAULT_SUMMARY_MAX_TOKENS, # Max tokens in an agent-submitted summary
|
|
428
|
+
"raw_tail_size": DEFAULT_RAW_TAIL_SIZE, # Verbatim recent descriptions kept outside the rolling summary
|
|
429
|
+
|
|
430
|
+
# ====================================================================
|
|
431
|
+
# TOKEN LIMITS (used by utils.py, merge.py, query.py)
|
|
432
|
+
# Prevents text from exceeding API token limits
|
|
433
|
+
# ====================================================================
|
|
434
|
+
"embedding_token_limit": int(os.getenv("GRAPHRAG_EMBEDDING_TOKEN_LIMIT", "0")) or None,
|
|
435
|
+
"max_entity_tokens": DEFAULT_MAX_ENTITY_TOKENS, # Max tokens per entity description
|
|
436
|
+
"max_relation_tokens": DEFAULT_MAX_RELATION_TOKENS, # Max tokens per relation description
|
|
437
|
+
"max_total_tokens": DEFAULT_MAX_TOTAL_TOKENS, # Total token budget per query
|
|
438
|
+
|
|
439
|
+
# ====================================================================
|
|
440
|
+
# KNOWLEDGE GRAPH SETTINGS (used by merge.py, query.py)
|
|
441
|
+
# Controls entity/relation retrieval and filtering
|
|
442
|
+
# ====================================================================
|
|
443
|
+
"kg_evidence_top_k": DEFAULT_KG_EVIDENCE_TOP_K, # Global cap after all evidence sources are merged
|
|
444
|
+
"kg_evidence_min_similarity": DEFAULT_KG_EVIDENCE_MIN_SIMILARITY, # Reject weak evidence
|
|
445
|
+
"yoy_signal_phrases": DEFAULT_YOY_SIGNAL_PHRASES, # Comparison-query phrases (query.py mode upgrade)
|
|
446
|
+
|
|
447
|
+
# ====================================================================
|
|
448
|
+
# SOURCE ID TRACKING (used by merge.py)
|
|
449
|
+
# Controls how many source references (chunk IDs) to keep per entity/relation
|
|
450
|
+
# When re-ingesting, older sources may be dropped if limit is reached
|
|
451
|
+
# ====================================================================
|
|
452
|
+
"max_source_ids_per_entity": 64, # Max source chunks to track per entity
|
|
453
|
+
"max_source_ids_per_relation": 64, # Max source chunks to track per relation
|
|
454
|
+
"source_ids_limit_method": DEFAULT_SOURCE_IDS_LIMIT_METHOD, # "KEEP" old or "FIFO" newest
|
|
455
|
+
|
|
456
|
+
# ====================================================================
|
|
457
|
+
# FILE PATH TRACKING (used by merge.py)
|
|
458
|
+
# Limits how many file paths are stored per entity/relation
|
|
459
|
+
# ====================================================================
|
|
460
|
+
"max_file_paths": DEFAULT_MAX_FILE_PATHS, # Max file paths to store
|
|
461
|
+
"file_path_more_placeholder": DEFAULT_FILE_PATH_MORE_PLACEHOLDER, # Placeholder for truncated paths
|
|
462
|
+
|
|
463
|
+
# ====================================================================
|
|
464
|
+
# GRAPH FILTERING (used by query.py)
|
|
465
|
+
# Controls graph search and result filtering
|
|
466
|
+
# ====================================================================
|
|
467
|
+
"max_graph_nodes": 1000, # Max nodes to consider in graph traversal
|
|
468
|
+
"min_rerank_score": 0.0, # Min score to include in results
|
|
469
|
+
|
|
470
|
+
# ====================================================================
|
|
471
|
+
# PROMPTS (used by query.py)
|
|
472
|
+
# ====================================================================
|
|
473
|
+
"system_prompt_template": PROMPTS["rag_response"], # Default system prompt for queries
|
|
474
|
+
}
|
|
475
|
+
if extra:
|
|
476
|
+
config.update(extra)
|
|
477
|
+
return config
|
core/backup.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Offline, verified snapshots of the supported local artifact set."""
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
import fcntl
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import shutil
|
|
10
|
+
import stat
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
from core.file_io import atomic_artifact_path
|
|
14
|
+
from core.session_lock import graph_directory_owner
|
|
15
|
+
from core.transactions import ARTIFACTS, JOURNAL
|
|
16
|
+
from core.utils import load_json, write_json
|
|
17
|
+
|
|
18
|
+
MANIFEST = "backup_manifest.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _digest(path):
|
|
22
|
+
if path.is_symlink() or not path.is_file():
|
|
23
|
+
raise ValueError(f"Backup artifact must be a regular file: {path.name}")
|
|
24
|
+
digest = hashlib.sha256()
|
|
25
|
+
size = 0
|
|
26
|
+
with path.open("rb") as stream:
|
|
27
|
+
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
28
|
+
digest.update(block)
|
|
29
|
+
size += len(block)
|
|
30
|
+
return {"sha256": digest.hexdigest(), "bytes": size}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def verify_backup(source):
|
|
34
|
+
source = Path(source).resolve()
|
|
35
|
+
if (source / MANIFEST).is_symlink():
|
|
36
|
+
raise ValueError("Backup manifest must not be a symlink")
|
|
37
|
+
manifest = load_json(str(source / MANIFEST))
|
|
38
|
+
if not manifest or manifest.get("format_version") != 1:
|
|
39
|
+
raise ValueError("Missing or unsupported backup manifest")
|
|
40
|
+
files = manifest.get("files")
|
|
41
|
+
if not isinstance(files, dict) or not files or any(name not in ARTIFACTS for name in files):
|
|
42
|
+
raise ValueError("Invalid backup artifact list")
|
|
43
|
+
if {p.name for p in source.iterdir()} != set(files) | {MANIFEST}:
|
|
44
|
+
raise ValueError("Unexpected or missing backup files")
|
|
45
|
+
for name, expected in files.items():
|
|
46
|
+
if not isinstance(expected, dict) or _digest(source / name) != expected:
|
|
47
|
+
raise ValueError(f"Backup checksum mismatch: {name}")
|
|
48
|
+
return manifest
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@contextmanager
|
|
52
|
+
def _fresh_directory(destination):
|
|
53
|
+
"""Publish only a complete directory; never intentionally replace a target."""
|
|
54
|
+
destination = Path(destination).absolute()
|
|
55
|
+
if destination.exists() or destination.is_symlink():
|
|
56
|
+
raise FileExistsError(f"Destination already exists: {destination}")
|
|
57
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
staging = Path(tempfile.mkdtemp(prefix=f".{destination.name}-", dir=destination.parent))
|
|
59
|
+
try:
|
|
60
|
+
yield staging
|
|
61
|
+
if destination.exists() or destination.is_symlink():
|
|
62
|
+
raise FileExistsError(f"Destination appeared during copy: {destination}")
|
|
63
|
+
os.rename(staging, destination)
|
|
64
|
+
descriptor = os.open(destination.parent, os.O_RDONLY)
|
|
65
|
+
try:
|
|
66
|
+
os.fsync(descriptor)
|
|
67
|
+
finally:
|
|
68
|
+
os.close(descriptor)
|
|
69
|
+
finally:
|
|
70
|
+
if staging.exists():
|
|
71
|
+
shutil.rmtree(staging)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _copy(source, destination):
|
|
75
|
+
# Reject links and special files before reading. O_NOFOLLOW also protects
|
|
76
|
+
# the final source path component if it changes between inspection/open.
|
|
77
|
+
if source.is_symlink() or not source.is_file():
|
|
78
|
+
raise ValueError(f"Artifact must be a regular file: {source.name}")
|
|
79
|
+
descriptor = os.open(source, os.O_RDONLY | os.O_NOFOLLOW)
|
|
80
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
81
|
+
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
|
82
|
+
raise ValueError(f"Artifact must be a regular file: {source.name}")
|
|
83
|
+
with atomic_artifact_path(destination) as temporary:
|
|
84
|
+
with temporary.open("wb") as output:
|
|
85
|
+
shutil.copyfileobj(stream, output, length=1024 * 1024)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def backup_graph(source, destination):
|
|
89
|
+
source = Path(source).resolve()
|
|
90
|
+
if not source.is_dir():
|
|
91
|
+
raise ValueError("Source graph directory does not exist")
|
|
92
|
+
target = Path(destination).absolute()
|
|
93
|
+
if target.resolve().is_relative_to(source):
|
|
94
|
+
raise ValueError("Backup destination must be outside the graph directory")
|
|
95
|
+
# Stop the server first. These nonblocking locks also reject standalone
|
|
96
|
+
# mutations that use the session lock without owning a server runtime.
|
|
97
|
+
with graph_directory_owner(str(source)):
|
|
98
|
+
descriptor = os.open(source / ".preciso-ingestion.lock", os.O_CREAT | os.O_RDWR, 0o600)
|
|
99
|
+
try:
|
|
100
|
+
try:
|
|
101
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
102
|
+
except BlockingIOError as exc:
|
|
103
|
+
raise RuntimeError("Graph mutation is active; stop writers before backup") from exc
|
|
104
|
+
if (source / JOURNAL).exists() or (source / JOURNAL).is_symlink():
|
|
105
|
+
raise RuntimeError("Unfinished transaction; recover the graph before backup")
|
|
106
|
+
files = [name for name in ARTIFACTS if (source / name).exists() or (source / name).is_symlink()]
|
|
107
|
+
if not files:
|
|
108
|
+
raise ValueError("No persisted graph artifacts to back up")
|
|
109
|
+
with _fresh_directory(target) as staging:
|
|
110
|
+
for name in files:
|
|
111
|
+
_copy(source / name, staging / name)
|
|
112
|
+
manifest = {
|
|
113
|
+
"format_version": 1,
|
|
114
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
115
|
+
"files": {name: _digest(staging / name) for name in files},
|
|
116
|
+
}
|
|
117
|
+
write_json(manifest, str(staging / MANIFEST))
|
|
118
|
+
verify_backup(staging)
|
|
119
|
+
return manifest
|
|
120
|
+
finally:
|
|
121
|
+
os.close(descriptor)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def restore_graph(source, destination):
|
|
125
|
+
source = Path(source).resolve()
|
|
126
|
+
manifest = verify_backup(source)
|
|
127
|
+
if Path(destination).resolve().is_relative_to(source):
|
|
128
|
+
raise ValueError("Restore destination must be outside the backup directory")
|
|
129
|
+
with _fresh_directory(destination) as staging:
|
|
130
|
+
for name, expected in manifest["files"].items():
|
|
131
|
+
_copy(source / name, staging / name)
|
|
132
|
+
if _digest(staging / name) != expected:
|
|
133
|
+
raise ValueError(f"Backup changed during restore: {name}")
|
|
134
|
+
return manifest
|