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.
Files changed (48) hide show
  1. config.py +477 -0
  2. core/backup.py +134 -0
  3. core/bootstrap.py +84 -0
  4. core/documents.py +444 -0
  5. core/export_adapters.py +392 -0
  6. core/file_io.py +43 -0
  7. core/local_embeddings.py +147 -0
  8. core/merge.py +548 -0
  9. core/query.py +1030 -0
  10. core/runtime_status.py +224 -0
  11. core/session_lock.py +74 -0
  12. core/storage/base.py +397 -0
  13. core/storage/graph_store.py +231 -0
  14. core/storage/kv_store.py +164 -0
  15. core/storage/shared_storage.py +116 -0
  16. core/storage/vector_store.py +285 -0
  17. core/storage/vector_write_batch.py +45 -0
  18. core/summary.py +153 -0
  19. core/summary_maintenance.py +335 -0
  20. core/transactions.py +182 -0
  21. core/utils.py +663 -0
  22. ingest/_materialize.py +418 -0
  23. ingest/file_access.py +39 -0
  24. ingest/parser.py +53 -0
  25. ingest/pipeline.py +8 -0
  26. ingest/reconciler.py +250 -0
  27. ingest/transformer.py +77 -0
  28. ingest/validator.py +161 -0
  29. preciso_graphrag-0.3.0rc1.dist-info/METADATA +601 -0
  30. preciso_graphrag-0.3.0rc1.dist-info/RECORD +48 -0
  31. preciso_graphrag-0.3.0rc1.dist-info/WHEEL +5 -0
  32. preciso_graphrag-0.3.0rc1.dist-info/entry_points.txt +3 -0
  33. preciso_graphrag-0.3.0rc1.dist-info/licenses/LICENSE +201 -0
  34. preciso_graphrag-0.3.0rc1.dist-info/top_level.txt +4 -0
  35. preciso_mcp/__init__.py +0 -0
  36. preciso_mcp/backup_cli.py +31 -0
  37. preciso_mcp/cli.py +63 -0
  38. preciso_mcp/server.py +610 -0
  39. preciso_mcp/setup_cli.py +299 -0
  40. preciso_mcp/tools/__init__.py +0 -0
  41. preciso_mcp/tools/checkpoint_tool.py +36 -0
  42. preciso_mcp/tools/export_tool.py +41 -0
  43. preciso_mcp/tools/ingest_from_file_tool.py +178 -0
  44. preciso_mcp/tools/pending_summaries_tool.py +8 -0
  45. preciso_mcp/tools/query_tool.py +35 -0
  46. preciso_mcp/tools/reconcile_tool.py +206 -0
  47. preciso_mcp/tools/status_tool.py +11 -0
  48. 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