From d0eee7aebc9dcc81850034318d9b6260d35f6dba Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 25 Sep 2026 06:24:06 +0000 Subject: [PATCH] Release 0.6.0 ingestion and retrieval hardening --- CHANGELOG.md | 29 +++ README.md | 11 +- config/falkordb.example.yaml | 3 + config/ladybug.example.yaml | 3 + config/neo4j.example.yaml | 3 + config/ollama.example.yaml | 3 + docs/reference.md | 41 +++- pyproject.toml | 4 +- server.json | 4 +- src/kg_mcp/cli.py | 36 +++- src/kg_mcp/config.py | 8 + src/kg_mcp/dedup.py | 86 ++++++++ src/kg_mcp/ingest.py | 397 ++++++++++++++++++++++++++++++++--- src/kg_mcp/retrieval.py | 22 ++ src/kg_mcp/review.py | 168 +++++++++++++++ src/kg_mcp/runtime.py | 46 +++- src/kg_mcp/server.py | 29 ++- tests/test_v04_release.py | 6 +- tests/test_v06_release.py | 352 +++++++++++++++++++++++++++++++ uv.lock | 96 ++++----- 20 files changed, 1241 insertions(+), 106 deletions(-) create mode 100644 src/kg_mcp/dedup.py create mode 100644 src/kg_mcp/review.py create mode 100644 tests/test_v06_release.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e7f792..4ea06c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## 0.6.0 + +### Added + +- Episode ingestion retries transient model and database failures with bounded + exponential backoff. Every attempt reuses a content-derived episode UUID, successful + writes return and persist canonical episode/node/edge receipts, and exhausted failures + are appended to a durable failure ledger for reconciliation. +- Staged extraction writes an `accept` / `refuse` / `contested` decision template for + every fact edge. Promotion refuses unresolved decisions, filters refused facts, + re-seals the approved snapshot, and records both the source and decision digests. +- `kg-ingest` accepts Markdown, text, JSON, JSONL, and directory trees. Text is split at + stable paragraph and sentence boundaries before it reaches the model; empty or + unsupported inputs fail explicitly instead of reporting a zero-record success. +- CLI and MCP retrieval responses include candidate, eligible, returned, and suppressed + counts plus `recall_may_be_incomplete` when the bounded candidate ceiling was reached. +- OpenAI-compatible local models receive schemas whose nullable properties are present + in `required`, including nested temporal fields, so constrained decoding emits a key + with `null` rather than silently omitting it. +- Entity fuzzy-match ties use a stable UUID ordering, independent of + `PYTHONHASHSEED` and worker process. + +### Changed + +- **`graphiti-core` is pinned to 0.30.2** for request-scoped FalkorDB routing and the + indexed edge-search fixes in that release. +- `kg-ingest` splits source bodies above 12,000 characters by default. Pass + `--max-chars 0` to retain one episode per input record. + ## 0.5.0 ### Added diff --git a/README.md b/README.md index 76468ff..37f8ba3 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,17 @@ to propose PostgreSQL, review and apply that update, and retrieve it from an MCP Model extraction can be wrong; inspect the returned facts and validity timestamps. To review extraction before it touches the configured graph, run -`kg-ingest INPUT --review-output review.jsonl`, inspect the snapshot, then use the -printed restore command to promote those exact records. Searches return current facts +`kg-ingest INPUT --review-output review.jsonl`, inspect the snapshot, and mark every +fact in the generated `review.jsonl.review.jsonl` file as `accept`, `refuse`, or +`contested`. The printed restore command refuses unresolved decisions and promotes a +checksum-sealed snapshot containing accepted facts only. Searches return current facts by default; `kg ask --history` is the explicit historical view. +`kg-ingest` also accepts a Markdown/text file or a directory tree directly. Large +documents split at stable text boundaries. Transient writes retry with one stable +episode id; `ingest-receipts.jsonl` and `ingest-failures.jsonl` in the workspace show +exactly what landed and what still needs attention. + ## Multiple users on Ladybug Give each user a group, and each group its own Ladybug file: diff --git a/config/falkordb.example.yaml b/config/falkordb.example.yaml index e9ce438..f719e9e 100644 --- a/config/falkordb.example.yaml +++ b/config/falkordb.example.yaml @@ -20,6 +20,8 @@ graph: # when a backend does not answer in time. query_timeout_seconds: 30 writer_lock_timeout_seconds: 30 + ingest_max_attempts: 3 + ingest_retry_base_seconds: 1 database: provider: falkordb @@ -38,6 +40,7 @@ llm: api_mode: auto # Hard ceiling for every extraction call, including larger upstream prompt requests. max_tokens: 4096 + require_all_schema_properties: true embedder: model: ${KG_EMBEDDER_MODEL:text-embedding-3-small} diff --git a/config/ladybug.example.yaml b/config/ladybug.example.yaml index 7c5d151..f4d6274 100644 --- a/config/ladybug.example.yaml +++ b/config/ladybug.example.yaml @@ -18,6 +18,8 @@ graph: # when a backend does not answer in time. query_timeout_seconds: 30 writer_lock_timeout_seconds: 30 + ingest_max_attempts: 3 + ingest_retry_base_seconds: 1 database: provider: ladybug @@ -34,6 +36,7 @@ llm: structured_output_mode: json_schema api_mode: auto max_tokens: 4096 + require_all_schema_properties: true embedder: model: ${KG_EMBEDDER_MODEL:text-embedding-3-small} diff --git a/config/neo4j.example.yaml b/config/neo4j.example.yaml index 541be52..ff9d83e 100644 --- a/config/neo4j.example.yaml +++ b/config/neo4j.example.yaml @@ -18,6 +18,8 @@ graph: # when a backend does not answer in time. query_timeout_seconds: 30 writer_lock_timeout_seconds: 30 + ingest_max_attempts: 3 + ingest_retry_base_seconds: 1 database: provider: neo4j @@ -33,6 +35,7 @@ llm: structured_output_mode: json_schema api_mode: auto max_tokens: 4096 + require_all_schema_properties: true embedder: model: ${KG_EMBEDDER_MODEL:text-embedding-3-small} diff --git a/config/ollama.example.yaml b/config/ollama.example.yaml index 71813aa..0a099a8 100644 --- a/config/ollama.example.yaml +++ b/config/ollama.example.yaml @@ -31,6 +31,8 @@ graph: # when a backend does not answer in time. query_timeout_seconds: 30 writer_lock_timeout_seconds: 30 + ingest_max_attempts: 3 + ingest_retry_base_seconds: 1 database: provider: ladybug @@ -45,6 +47,7 @@ llm: temperature: 0 api_mode: chat max_tokens: 4096 + require_all_schema_properties: true embedder: model: ${KG_EMBEDDER_MODEL:nomic-embed-text} diff --git a/docs/reference.md b/docs/reference.md index ceb4dfd..235cd5d 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -135,30 +135,45 @@ Ingestion is a separate command and is dry-run by default: ```bash uv run kg-ingest examples/local_memory_demo.jsonl uv run kg-ingest examples/local_memory_demo.jsonl --apply +uv run kg-ingest ./docs --domain example --apply ``` -Inputs are UTF-8 JSONL objects with `name` and `body`; `domain`, `valid_at`, and -`provenance` are optional. A requested domain must be in `graph.groups`. +Inputs may be UTF-8 JSONL/JSON records, Markdown/text files, or a directory containing +those types. JSON records need `name` and `body`; `domain`, `valid_at`, and `provenance` +are optional. `--domain` supplies the graph group for plain documents. A requested +domain must be in `graph.groups`. Bodies split at stable paragraph/sentence boundaries +above 12,000 characters; choose another bound with `--max-chars`, or pass `0` to keep +one episode per source record. Empty and unsupported inputs fail explicitly. Ingestion is resumable. Each applied record is written to a content-keyed ledger in the workspace, so re-running the same file ingests only what has not landed yet -instead of duplicating it. A record that fails is isolated and reported; the rest of -the batch still lands, and the failure sets a non-zero exit code. Use `--no-resume` -to ignore the ledger and `--fail-fast` for the old stop-at-first-error behaviour. +instead of duplicating it. Each record has a content-derived episode UUID. Transient +model and backend errors retry with bounded backoff; a record that still fails is +isolated and reported, the rest of the batch lands, and the command exits non-zero. +`ingest-receipts.jsonl` records canonical episode ids and node/edge counts; +`ingest-failures.jsonl` records exhausted attempts for reconciliation. Use +`--no-resume` to ignore the ledger and `--fail-fast` to stop after the first exhausted +record. To inspect model extraction before it reaches the configured graph, stage it: ```bash kg-ingest notes.jsonl --review-output ./review.jsonl # inspect the entity_node, entity_edge, and episodic records in review.jsonl -kg-ingest ./review.jsonl --restore --group team-a --apply +# edit review.jsonl.review.jsonl: review -> accept, refuse, or contested +kg-ingest ./review.jsonl --restore \ + --review-decisions ./review.jsonl.review.jsonl \ + --approved-output ./review.approved.jsonl --group team-a --apply ``` The first command creates a disposable embedded graph, runs extraction there, exports the exact resolved records, and removes the temporary database. It never opens the configured production backend. One review snapshot accepts one domain so its promotion -target remains explicit. The printed `promote` command restores the reviewed records -without running extraction again. +target remains explicit. The decision file names the source snapshot digest and one +typed decision per fact. Promotion refuses missing, `review`, or `contested` rows, +drops refused fact edges, updates episode provenance lists, and writes a new canonical +snapshot carrying source and decision digests. The printed `promote` command restores +that sealed result without running extraction again. `SIGTERM` and `SIGINT` stop it at a record boundary rather than mid-write: it finishes the record in flight, closes the driver, and reports `interrupted`. This matters when a @@ -228,7 +243,10 @@ other correction. `reranker.candidate_multiplier` controls how many RRF candidates reach the configured cross encoder, and `reranker.min_score` filters only its final scores. Equal fact text does not collapse distinct edge UUIDs. `passthrough` keeps the original order without -an extra model call. +an extra model call. CLI and MCP search payloads include a `retrieval` object with the +requested limit, candidate ceiling, candidates seen, eligible and returned counts, and +`recall_may_be_incomplete`. The flag is true whenever the upstream candidate ceiling +was reached, so an empty or short filtered result cannot masquerade as exhaustive. FalkorDB queries remove only a standalone `_` token, which RediSearch reserves, and a multi-label node search fans out by label before merging UUIDs. Identifiers such as @@ -238,7 +256,10 @@ multi-label node search fans out by label before merging UUIDs. Identifiers such upstream prompt that requests a larger budget. `llm.api_mode` selects `responses`, `chat`, or `auto`; `auto` uses Responses on the official OpenAI endpoint and Chat Completions for compatible endpoints. Set it explicitly for a proxy whose URL does -not reveal which API family it implements. +not reveal which API family it implements. For grammar-backed compatible endpoints, +`llm.require_all_schema_properties` defaults to true: nullable schema fields remain +nullable but their keys are required, preventing temporal keys from vanishing during +constrained decoding. Run the release gate before sharing: diff --git a/pyproject.toml b/pyproject.toml index 7135aaa..91d4918 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "graphiti-local" -version = "0.5.0" +version = "0.6.0" description = "A local-first temporal knowledge graph with a read-only MCP server and CLI" readme = "README.md" requires-python = ">=3.10,<4" @@ -20,7 +20,7 @@ classifiers = [ ] dependencies = [ "filelock>=3.16,<4", - "graphiti-core[falkordb]==0.30.1", + "graphiti-core[falkordb]==0.30.2", "ladybug>=0.19.1,<1", "mcp>=1.27,<2", "pydantic>=2.11,<3", diff --git a/server.json b/server.json index 398f38f..dd7e1fb 100644 --- a/server.json +++ b/server.json @@ -7,12 +7,12 @@ "url": "https://github.com/renezander030/graphiti-local", "source": "github" }, - "version": "0.5.0", + "version": "0.6.0", "packages": [ { "registryType": "pypi", "identifier": "graphiti-local", - "version": "0.5.0", + "version": "0.6.0", "transport": { "type": "stdio" } diff --git a/src/kg_mcp/cli.py b/src/kg_mcp/cli.py index b91b90c..44c686a 100644 --- a/src/kg_mcp/cli.py +++ b/src/kg_mcp/cli.py @@ -87,7 +87,7 @@ async def _edge_search( args: argparse.Namespace, groups: list[str] | None, settings: Any, -) -> tuple[list[tuple[Any, float]], int]: +) -> tuple[list[tuple[Any, float]], dict[str, Any]]: """Return current, identity-safe reranked edges.""" from kg_mcp.retrieval import ( candidate_limit, @@ -95,6 +95,7 @@ async def _edge_search( current_edges, keyword_edge_search_config, rerank_edges, + retrieval_stats, ) query = compatible_query(args.query, settings) @@ -111,7 +112,15 @@ async def _edge_search( current, suppressed = current_edges( list(edges), include_invalidated=getattr(args, "history", False) ) - return await rerank_edges(graph, query, current, settings, args.limit), suppressed + ranked = await rerank_edges(graph, query, current, settings, args.limit) + return ranked, retrieval_stats( + requested=args.limit, + candidate_ceiling=candidates, + candidates_seen=len(edges), + eligible=len(current), + returned=len(ranked), + suppressed=suppressed, + ) async def _graph_command(args: argparse.Namespace) -> dict[str, Any]: @@ -166,7 +175,7 @@ async def _graph_command_on(args: argparse.Namespace, settings: Any) -> dict[str try: if args.command == "ask": groups = allowed_groups(args.groups, settings) - results, suppressed = await bounded( + results, retrieval = await bounded( _edge_search(graph, args, groups, settings), timeout, "ask", @@ -186,7 +195,8 @@ async def _graph_command_on(args: argparse.Namespace, settings: Any) -> dict[str } for result, score in results ], - "suppressed_invalidated": suppressed, + "suppressed_invalidated": retrieval["suppressed"], + "retrieval": retrieval, "ranker": settings.reranker.provider, "pending": _pending_items(groups or settings.graph.groups), } @@ -196,26 +206,38 @@ async def _graph_command_on(args: argparse.Namespace, settings: Any) -> dict[str candidate_limit, compatible_query, keyword_node_search_config, + retrieval_stats, ) - candidate_limit(args.limit, settings) + candidates = candidate_limit(args.limit, settings) query = compatible_query(args.query, settings) from graphiti_core.search.search_config_recipes import NODE_HYBRID_SEARCH_RRF node_config = ( - keyword_node_search_config(args.limit) if args.keyword else NODE_HYBRID_SEARCH_RRF + keyword_node_search_config(candidates) + if args.keyword + else NODE_HYBRID_SEARCH_RRF.model_copy(update={"limit": candidates}) ) result = await bounded( graph.search_(query, config=node_config, group_ids=groups), timeout, "nodes", ) + nodes = list(result.nodes or []) + returned = nodes[: args.limit] return { "query": args.query, "nodes": [ {"name": node.name, "group_id": node.group_id, "uuid": node.uuid} - for node in result.nodes[: args.limit] + for node in returned ], + "retrieval": retrieval_stats( + requested=args.limit, + candidate_ceiling=candidates, + candidates_seen=len(nodes), + eligible=len(nodes), + returned=len(returned), + ), } if args.command == "episodes": groups = allowed_groups(args.groups, settings) diff --git a/src/kg_mcp/config.py b/src/kg_mcp/config.py index f2bec6c..c670d6f 100644 --- a/src/kg_mcp/config.py +++ b/src/kg_mcp/config.py @@ -115,6 +115,10 @@ class GraphConfig(BaseModel): # An embedded Ladybug graph has one writer. Wait this long for another ingest, # restore, drain, or setup command to finish before failing explicitly. writer_lock_timeout_seconds: float = Field(default=30.0, gt=0) + # Retry one episode rather than losing it when an inference endpoint or graph backend + # fails transiently. A content-derived episode UUID keeps those retries idempotent. + ingest_max_attempts: int = Field(default=3, ge=1, le=10) + ingest_retry_base_seconds: float = Field(default=1.0, ge=0, le=60) @model_validator(mode="after") def validate_groups(self) -> GraphConfig: @@ -137,6 +141,10 @@ class LLMConfig(OpenAIConfig): max_tokens: int = Field(default=4096, ge=1) structured_output_mode: Literal["json_schema", "json_object"] = "json_schema" api_mode: Literal["auto", "responses", "chat"] = "auto" + # Grammar-backed OpenAI-compatible servers commonly omit schema properties that + # are not listed in ``required``. Keep nullable fields nullable, but require their + # keys so temporal values such as valid_at/invalid_at cannot disappear silently. + require_all_schema_properties: bool = True class EmbedderConfig(OpenAIConfig): diff --git a/src/kg_mcp/dedup.py b/src/kg_mcp/dedup.py new file mode 100644 index 0000000..2064ee6 --- /dev/null +++ b/src/kg_mcp/dedup.py @@ -0,0 +1,86 @@ +"""Deterministic entity-resolution guard for the pinned Graphiti release.""" + +from __future__ import annotations + +from typing import Any + +_INSTALLED = False + + +def _resolve_with_similarity( + extracted_nodes: list[Any], + indexes: Any, + state: Any, +) -> None: + """Resolve equal fuzzy matches with a stable UUID tie-break. + + graphiti-core 0.30.x collects LSH hits in a set and keeps the first candidate + with the best score. Set iteration depends on ``PYTHONHASHSEED``. This equivalent + resolver sorts candidate UUIDs and explicitly breaks equal scores by UUID so two + workers ingesting the same episode choose the same canonical entity. + """ + from graphiti_core.utils.maintenance import dedup_helpers as helpers + + for idx, node in enumerate(extracted_nodes): + normalized_exact = helpers._normalize_string_exact(node.name) + normalized_fuzzy = helpers._normalize_name_for_fuzzy(node.name) + + existing_matches = indexes.normalized_existing.get(normalized_exact, []) + if len(existing_matches) == 1: + match = helpers._promote_resolved_node(node, existing_matches[0]) + state.resolved_nodes[idx] = match + state.uuid_map[node.uuid] = match.uuid + if match.uuid != node.uuid: + state.duplicate_pairs.append((node, match)) + continue + if len(existing_matches) > 1: + state.unresolved_indices.append(idx) + continue + + if not helpers._has_high_entropy(normalized_fuzzy): + state.unresolved_indices.append(idx) + continue + + shingles = helpers._cached_shingles(normalized_fuzzy) + signature = helpers._minhash_signature(shingles) + candidate_ids: set[str] = set() + for band_index, band in enumerate(helpers._lsh_bands(signature)): + candidate_ids.update(indexes.lsh_buckets.get((band_index, band), [])) + + best_candidate = None + best_score = 0.0 + for candidate_id in sorted(candidate_ids): + candidate = indexes.nodes_by_uuid.get(candidate_id) + if candidate is None: + continue + candidate_shingles = indexes.shingles_by_candidate.get(candidate_id, set()) + score = helpers._jaccard_similarity(shingles, candidate_shingles) + if score > best_score or ( + score == best_score + and best_candidate is not None + and candidate.uuid < best_candidate.uuid + ): + best_score = score + best_candidate = candidate + + if best_candidate is not None and best_score >= helpers._FUZZY_JACCARD_THRESHOLD: + best_candidate = helpers._promote_resolved_node(node, best_candidate) + state.resolved_nodes[idx] = best_candidate + state.uuid_map[node.uuid] = best_candidate.uuid + if best_candidate.uuid != node.uuid: + state.duplicate_pairs.append((node, best_candidate)) + continue + + state.unresolved_indices.append(idx) + + +def install_deterministic_tie_break() -> None: + """Install the process-wide resolver once, including the imported call-site alias.""" + global _INSTALLED + if _INSTALLED: + return + from graphiti_core.utils.maintenance import dedup_helpers, node_operations + + dedup_helpers._resolve_with_similarity = _resolve_with_similarity + node_operations._resolve_with_similarity = _resolve_with_similarity + _INSTALLED = True diff --git a/src/kg_mcp/ingest.py b/src/kg_mcp/ingest.py index 25fec33..d8d86e2 100644 --- a/src/kg_mcp/ingest.py +++ b/src/kg_mcp/ingest.py @@ -11,9 +11,11 @@ import asyncio import hashlib import json +import re import shlex import signal import tempfile +import uuid from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path @@ -22,6 +24,9 @@ from kg_mcp.config import Settings, allowed_groups, load_config LEDGER = "ingest-ledger.jsonl" +RECEIPTS = "ingest-receipts.jsonl" +FAILURES = "ingest-failures.jsonl" +TEXT_SUFFIXES = {".md", ".markdown", ".txt"} def read_records(path: Path, *, skip_invalid: bool = False) -> list[dict[str, Any]]: @@ -50,6 +55,172 @@ def read_records(path: Path, *, skip_invalid: bool = False) -> list[dict[str, An return records +def _split_long_piece(piece: str, max_chars: int) -> list[str]: + """Split one overlong paragraph without losing any non-whitespace text.""" + pieces: list[str] = [] + remaining = piece.strip() + while len(remaining) > max_chars: + window = remaining[: max_chars + 1] + boundaries = [window.rfind(mark) for mark in ("\n", ". ", "! ", "? ", " ")] + cut = max(boundaries) + if cut < max_chars // 2: + cut = max_chars + elif window[cut : cut + 2] in {". ", "! ", "? "}: + cut += 1 + pieces.append(remaining[:cut].strip()) + remaining = remaining[cut:].strip() + if remaining: + pieces.append(remaining) + return pieces + + +def chunk_text(text: str, max_chars: int) -> list[str]: + """Make stable, paragraph-aware chunks bounded by ``max_chars``.""" + stripped = text.strip() + if not stripped: + return [] + if max_chars <= 0 or len(stripped) <= max_chars: + return [stripped] + + paragraphs = [part.strip() for part in re.split(r"\n\s*\n", stripped) if part.strip()] + atomic = [ + piece + for paragraph in paragraphs + for piece in _split_long_piece(paragraph, max_chars) + ] + chunks: list[str] = [] + current = "" + for piece in atomic: + candidate = f"{current}\n\n{piece}" if current else piece + if current and len(candidate) > max_chars: + chunks.append(current) + current = piece + else: + current = candidate + if current: + chunks.append(current) + return chunks + + +def _chunk_record(record: dict[str, Any], max_chars: int) -> list[dict[str, Any]]: + chunks = chunk_text(str(record.get("body", "")), max_chars) + if not chunks: + return [] + if len(chunks) == 1: + item = dict(record) + item["body"] = chunks[0] + return [item] + name = str(record.get("name", "document")).strip() + result = [] + for index, body in enumerate(chunks, 1): + item = dict(record) + item.update( + name=f"{name} [part {index}/{len(chunks)}]", + body=body, + chunk_index=index, + chunk_count=len(chunks), + ) + result.append(item) + return result + + +def read_input( + path: Path, + *, + input_format: str = "auto", + domain: str | None = None, + max_chars: int = 12000, + skip_invalid: bool = False, +) -> list[dict[str, Any]]: + """Read JSONL/JSON records or local text documents and directories. + + Directory discovery is deterministic, does not follow paths that resolve outside + the requested root, and fails when no supported non-empty source was found. + """ + source = path.expanduser() + if not source.exists(): + raise FileNotFoundError(source) + + if source.is_dir(): + root = source.resolve() + records: list[dict[str, Any]] = [] + for candidate in sorted(item for item in source.rglob("*") if item.is_file()): + resolved = candidate.resolve() + relative = candidate.relative_to(source) + hidden = any(part.startswith(".") for part in relative.parts) + if not resolved.is_relative_to(root) or hidden: + continue + suffix = candidate.suffix.lower() + if suffix not in TEXT_SUFFIXES | {".json", ".jsonl"}: + continue + discovered = read_input( + candidate, + input_format="auto", + domain=domain, + max_chars=max_chars, + skip_invalid=skip_invalid, + ) + for record in discovered: + if record.get("name") == candidate.name: + record["name"] = relative.as_posix() + record["provenance"] = f"local document: {relative.as_posix()}" + records.extend(discovered) + if not records: + raise ValueError(f"{source}: no supported non-empty input files") + return records + + selected = input_format + if selected == "auto": + selected = ( + "jsonl" + if source.suffix.lower() == ".jsonl" + else "json" + if source.suffix.lower() == ".json" + else "text" + if source.suffix.lower() in TEXT_SUFFIXES + else "" + ) + if selected == "jsonl": + raw_records = read_records(source, skip_invalid=skip_invalid) + elif selected == "json": + try: + payload = json.loads(source.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"{source}: invalid JSON: {exc.msg}") from exc + items = payload if isinstance(payload, list) else [payload] + if not items or not all(isinstance(item, dict) for item in items): + raise ValueError(f"{source}: JSON input must be an object or non-empty object list") + raw_records = list(items) + for item in raw_records: + if not str(item.get("name", "")).strip() or not str(item.get("body", "")).strip(): + raise ValueError(f"{source}: every JSON record needs non-empty name and body") + elif selected == "text": + body = source.read_text(encoding="utf-8").strip() + if not body: + raise ValueError(f"{source}: input contains no text") + raw_records = [ + { + "name": source.name, + "body": body, + "provenance": f"local document: {source.name}", + } + ] + else: + raise ValueError( + f"{source}: unsupported input type; use .jsonl, .json, .md, .markdown, or .txt" + ) + + prepared: list[dict[str, Any]] = [] + for record in raw_records: + item = dict(record) + if domain and not item.get("domain"): + item["domain"] = domain + prepared.extend(_chunk_record(item, max_chars)) + if not prepared: + raise ValueError(f"{source}: input produced zero records") + return prepared + + def _reference_time(value: str | None) -> datetime: if not value: return datetime.now(timezone.utc) @@ -70,6 +241,11 @@ def record_key(record: dict[str, Any], domain: str) -> str: return hashlib.sha256(material.encode("utf-8")).hexdigest()[:32] +def episode_uuid(key: str) -> str: + """Stable episode identity used across retries and reconciliations.""" + return str(uuid.UUID(hex=key)) + + def _ledger_path() -> Path: from kg_mcp.workspace import workspace_dir @@ -113,6 +289,47 @@ def _record_completed(key: str, domain: str, name: str) -> None: handle.write(json.dumps(entry, ensure_ascii=False) + "\n") +def _record_event(filename: str, entry: dict[str, Any]) -> None: + from filelock import FileLock + + from kg_mcp.workspace import workspace_dir + + directory = workspace_dir() + directory.mkdir(parents=True, exist_ok=True) + with FileLock(str(directory / ".lock")), (directory / filename).open( + "a", encoding="utf-8" + ) as handle: + handle.write(json.dumps(entry, ensure_ascii=False, sort_keys=True) + "\n") + + +def _retryable(exc: Exception) -> bool: + if isinstance(exc, (TimeoutError, ConnectionError, OSError, json.JSONDecodeError)): + return True + if type(exc).__name__ in {"RateLimitError", "EmptyResponseError", "ValidationError"}: + return True + detail = str(exc).lower() + return any( + token in detail + for token in ( + "timeout", + "timed out", + "temporar", + "rate limit", + "empty response", + "connection", + "unavailable", + "try again", + ) + ) + + +def _retry_delay(base: float, attempt: int, key: str) -> float: + if base <= 0: + return 0.0 + jitter = (int(key[:8], 16) / 0xFFFFFFFF) * base * 0.25 + return base * (2 ** (attempt - 1)) + jitter + + @contextmanager def _stop_on_signal(): """Turn SIGTERM/SIGINT into a stop request honoured at the next record boundary. @@ -176,6 +393,7 @@ async def ingest_records( "failed": [], "interrupted": False, "planned": [], + "receipts": [], } for domain, subset in by_domain.items(): part = await ingest_records( @@ -191,6 +409,7 @@ async def ingest_records( merged[key] += part[key] merged["failed"].extend(part["failed"]) merged["planned"].extend(part["planned"]) + merged["receipts"].extend(part["receipts"]) merged["interrupted"] = merged["interrupted"] or part["interrupted"] if part["interrupted"] or (fail_fast and part["failed"]): break @@ -214,6 +433,7 @@ async def ingest_records( "ingested": 0, "failed": [], "interrupted": False, + "receipts": [], } from graphiti_core.nodes import EpisodeType @@ -230,7 +450,7 @@ async def ingest_records( from kg_mcp.write_lock import graph_writer_lock - ingested, failures, interrupted = 0, [], False + ingested, failures, receipts, interrupted = 0, [], [], False with graph_writer_lock(settings): graph = build_graphiti(settings, read_only=False) try: @@ -240,33 +460,74 @@ async def ingest_records( if stop["requested"]: interrupted = True break - try: - await graph.add_episode( - name=str(record["name"]).strip(), - episode_body=str(record["body"]).strip(), - source=EpisodeType.text, - source_description=str( - record.get("provenance") or "Graphiti Local JSONL import" - ), - reference_time=_reference_time(record.get("valid_at")), - group_id=None if settings.database.provider == "ladybug" else domain, - ) - except Exception as exc: # one bad episode must not cost the batch + result = None + attempts = 0 + last_error: Exception | None = None + while attempts < settings.graph.ingest_max_attempts: + attempts += 1 + try: + result = await graph.add_episode( + name=str(record["name"]).strip(), + episode_body=str(record["body"]).strip(), + source=EpisodeType.text, + source_description=str( + record.get("provenance") or "Graphiti Local import" + ), + reference_time=_reference_time(record.get("valid_at")), + group_id=( + None if settings.database.provider == "ladybug" else domain + ), + uuid=episode_uuid(key), + ) + last_error = None + break + except Exception as exc: # one bad episode must not cost the batch + last_error = exc + exhausted = attempts >= settings.graph.ingest_max_attempts + if not _retryable(exc) or exhausted: + break + delay = _retry_delay( + settings.graph.ingest_retry_base_seconds, attempts, key + ) + if delay: + await asyncio.sleep(delay) + if last_error is not None: + exc = last_error detail = str(exc).strip() or f"{type(exc).__name__} (no message)" - failures.append( - { - "name": record["name"], - "domain": domain, - "operation": "extract episode", - "error_type": type(exc).__name__, - "error": detail, - } - ) + failure = { + "key": key, + "episode_uuid": episode_uuid(key), + "name": record["name"], + "domain": domain, + "operation": "extract episode", + "error_type": type(exc).__name__, + "error": detail, + "attempts": attempts, + "retryable": _retryable(exc), + "failed_at": datetime.now(timezone.utc).isoformat(), + } + failures.append(failure) + if ledger: + _record_event(FAILURES, failure) if fail_fast: break continue if ledger: _record_completed(key, domain, str(record["name"]).strip()) + receipt = { + "key": key, + "episode_uuid": getattr(getattr(result, "episode", None), "uuid", None) + or episode_uuid(key), + "name": str(record["name"]).strip(), + "domain": domain, + "nodes": len(getattr(result, "nodes", []) or []), + "edges": len(getattr(result, "edges", []) or []), + "attempts": attempts, + "ingested_at": datetime.now(timezone.utc).isoformat(), + } + receipts.append(receipt) + if ledger: + _record_event(RECEIPTS, receipt) ingested += 1 if ingested and track_fingerprint: fingerprint.record(settings) @@ -280,6 +541,7 @@ async def ingest_records( "failed": failures, "interrupted": interrupted, "planned": [], + "receipts": receipts, } @@ -347,6 +609,20 @@ async def stage_extraction( finally: await graph.close() + decision_template = None + if destination.exists(): + from kg_mcp.review import create_decision_template + + decision_template = create_decision_template(destination) + + review_flags = "" + if decision_template is not None: + approved = destination.with_suffix(destination.suffix + ".approved.jsonl") + review_flags = ( + f" --review-decisions {shlex.quote(decision_template['output'])}" + f" --approved-output {shlex.quote(str(approved))}" + ) + return { "staged": True, "production_graph_changed": False, @@ -354,10 +630,12 @@ async def stage_extraction( "extracted": extraction["ingested"], "failed": extraction["failed"], "review_snapshot": snapshot, + "review_decisions": decision_template, "promote": ( f"kg-ingest {shlex.quote(str(destination))} --restore " + f"{review_flags.strip()} " f"--group {shlex.quote(domain)} --apply" - ), + ).replace(" ", " "), } @@ -379,6 +657,23 @@ def main() -> None: help="stop at the first failing record instead of isolating it", ) parser.add_argument("--skip-invalid", action="store_true", help="ignore malformed input lines") + parser.add_argument( + "--input-format", + choices=("auto", "jsonl", "json", "text"), + default="auto", + help="input type (default: infer it from the file extension)", + ) + parser.add_argument( + "--domain", + default=None, + help="group for document/JSON input records that do not already name one", + ) + parser.add_argument( + "--max-chars", + type=int, + default=12000, + help="paragraph-aware maximum episode size; 0 disables splitting", + ) parser.add_argument( "--restore", action="store_true", @@ -389,6 +684,16 @@ def main() -> None: default=None, help="with --restore: the group every restored record is written to", ) + parser.add_argument( + "--review-decisions", + type=Path, + help="with --restore: typed decisions for every staged fact edge", + ) + parser.add_argument( + "--approved-output", + type=Path, + help="with --review-decisions: destination for the filtered promotion snapshot", + ) parser.add_argument( "--review-output", type=Path, @@ -399,6 +704,16 @@ def main() -> None: ) args = parser.parse_args() as_json = not args.human + if (args.review_decisions or args.approved_output) and not args.restore: + fail( + "--review-decisions and --approved-output require --restore", + code=2, + as_json=as_json, + ) + return + if args.approved_output and not args.review_decisions: + fail("--approved-output requires --review-decisions", code=2, as_json=as_json) + return if args.review_output: if args.apply or args.restore: fail( @@ -408,7 +723,13 @@ def main() -> None: ) return try: - records = read_records(args.input, skip_invalid=args.skip_invalid) + records = read_input( + args.input, + input_format=args.input_format, + domain=args.domain, + max_chars=args.max_chars, + skip_invalid=args.skip_invalid, + ) result = asyncio.run( stage_extraction(records, args.review_output, fail_fast=args.fail_fast) ) @@ -426,6 +747,11 @@ def review_human() -> list[str]: f"review {snapshot['record_count']} graph record(s) in {snapshot['output']}", f"promote unchanged: {result['promote']}", ] + if result.get("review_decisions"): + lines.append( + f"record accept/refuse/contested decisions in " + f"{result['review_decisions']['output']}" + ) lines.extend( f"FAILED [{item['domain']}] {item['name']}: {item['error']}" for item in result["failed"] @@ -440,7 +766,13 @@ def review_human() -> list[str]: _restore_main(args, as_json=as_json) return try: - records = read_records(args.input, skip_invalid=args.skip_invalid) + records = read_input( + args.input, + input_format=args.input_format, + domain=args.domain, + max_chars=args.max_chars, + skip_invalid=args.skip_invalid, + ) result = asyncio.run( ingest_records( records, @@ -483,14 +815,27 @@ def _restore_main(args: argparse.Namespace, *, as_json: bool) -> None: from kg_mcp.restore import human_lines, restore_snapshot try: + source = args.input + review_receipt = None + if args.review_decisions: + from kg_mcp.review import apply_review_decisions + + review_receipt = apply_review_decisions( + args.input, + args.review_decisions, + args.approved_output, + ) + source = Path(review_receipt["output"]) result = asyncio.run( restore_snapshot( - args.input, + source, apply=args.apply, group=args.group, fail_fast=args.fail_fast, ) ) + if review_receipt is not None: + result["review_receipt"] = review_receipt except ValueError as exc: fail(str(exc), code=2, as_json=as_json) return diff --git a/src/kg_mcp/retrieval.py b/src/kg_mcp/retrieval.py index a45e44e..04cc288 100644 --- a/src/kg_mcp/retrieval.py +++ b/src/kg_mcp/retrieval.py @@ -73,6 +73,28 @@ def candidate_limit(limit: int, settings: Settings) -> int: return min(1000, limit * settings.reranker.candidate_multiplier) +def retrieval_stats( + *, + requested: int, + candidate_ceiling: int, + candidates_seen: int, + eligible: int, + returned: int, + suppressed: int = 0, +) -> dict[str, Any]: + """Explain when bounded candidate generation may have hidden further matches.""" + ceiling_reached = candidates_seen >= candidate_ceiling + return { + "requested": requested, + "candidate_limit": candidate_ceiling, + "candidates_seen": candidates_seen, + "eligible": eligible, + "returned": returned, + "suppressed": suppressed, + "recall_may_be_incomplete": ceiling_reached, + } + + def keyword_edge_search_config(limit: int = 10): """A graphiti ``SearchConfig`` for facts that matches on keywords alone. diff --git a/src/kg_mcp/review.py b/src/kg_mcp/review.py new file mode 100644 index 0000000..eb97c28 --- /dev/null +++ b/src/kg_mcp/review.py @@ -0,0 +1,168 @@ +"""Typed, auditable decisions over a staged extraction snapshot.""" + +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from kg_mcp.export import _atomic_snapshot, _json_line +from kg_mcp.restore import read_snapshot + +REVIEW_FORMAT_VERSION = 1 +DECISIONS = {"accept", "refuse", "contested", "review"} + + +def default_decisions_path(snapshot: Path) -> Path: + return snapshot.with_suffix(snapshot.suffix + ".review.jsonl") + + +def default_approved_path(snapshot: Path) -> Path: + return snapshot.with_suffix(snapshot.suffix + ".approved.jsonl") + + +def create_decision_template(snapshot: Path, output: Path | None = None) -> dict[str, Any]: + """Write one explicit decision row for every extracted fact edge.""" + header, records = read_snapshot(snapshot) + destination = output or default_decisions_path(snapshot) + edges = [record for record in records if record["kind"] == "entity_edge"] + review_header = { + "kind": "review", + "format_version": REVIEW_FORMAT_VERSION, + "created_at": datetime.now(timezone.utc).isoformat(), + "snapshot_sha256": header.get("sha256"), + "snapshot_record_count": len(records), + "decision_scope": "entity_edge", + "instructions": "change each decision from review to accept, refuse, or contested", + } + decision_rows = [ + { + "kind": "decision", + "record_kind": "entity_edge", + "uuid": edge["uuid"], + "fact": edge.get("fact", ""), + "decision": "review", + "reason": "", + } + for edge in edges + ] + _atomic_snapshot(destination, review_header, decision_rows) + return { + "output": str(destination), + "facts": len(edges), + "snapshot_sha256": header.get("sha256"), + } + + +def _read_decisions(path: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]: + header: dict[str, Any] | None = None + rows: list[dict[str, Any]] = [] + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if not line.strip(): + continue + try: + item = json.loads(line) + except json.JSONDecodeError as exc: + raise ValueError(f"{path}:{line_number}: invalid JSON: {exc.msg}") from exc + if not isinstance(item, dict): + raise ValueError(f"{path}:{line_number}: each line must be an object") + if header is None: + if item.get("kind") != "review": + raise ValueError(f"{path}: not a review decision file") + if item.get("format_version") != REVIEW_FORMAT_VERSION: + raise ValueError(f"{path}: unsupported review format {item.get('format_version')}") + header = item + continue + if item.get("kind") != "decision": + raise ValueError(f"{path}:{line_number}: expected a decision row") + if item.get("record_kind") != "entity_edge": + raise ValueError(f"{path}:{line_number}: decisions are scoped to entity_edge") + if item.get("decision") not in DECISIONS: + raise ValueError(f"{path}:{line_number}: invalid decision {item.get('decision')!r}") + if item.get("decision") == "refuse" and not str(item.get("reason") or "").strip(): + raise ValueError(f"{path}:{line_number}: refused facts require a reason") + rows.append(item) + if header is None: + raise ValueError(f"{path}: empty review decision file") + return header, rows + + +def apply_review_decisions( + snapshot: Path, + decisions: Path, + output: Path | None = None, +) -> dict[str, Any]: + """Create a checksum-valid promotion snapshot containing accepted fact edges only.""" + snapshot_header, records = read_snapshot(snapshot) + review_header, rows = _read_decisions(decisions) + if review_header.get("snapshot_sha256") != snapshot_header.get("sha256"): + raise ValueError( + f"{decisions}: decisions target snapshot {review_header.get('snapshot_sha256')}, " + f"not {snapshot_header.get('sha256')}" + ) + + expected = { + record["uuid"] for record in records if record.get("kind") == "entity_edge" + } + by_uuid: dict[str, dict[str, Any]] = {} + for row in rows: + uuid = str(row.get("uuid") or "") + if uuid in by_uuid: + raise ValueError(f"{decisions}: duplicate decision for {uuid}") + if uuid not in expected: + raise ValueError(f"{decisions}: decision references unknown fact edge {uuid}") + by_uuid[uuid] = row + missing = sorted(expected - set(by_uuid)) + if missing: + raise ValueError(f"{decisions}: no decision for fact edge {missing[0]}") + + unresolved = [row for row in rows if row["decision"] in {"review", "contested"}] + if unresolved: + first = unresolved[0] + raise ValueError( + f"{decisions}: {len(unresolved)} fact decision(s) are unresolved; " + f"first is {first['uuid']} ({first['decision']})" + ) + + refused = {uuid for uuid, row in by_uuid.items() if row["decision"] == "refuse"} + selected: list[dict[str, Any]] = [] + for original in records: + record = dict(original) + if record.get("kind") == "entity_edge" and record.get("uuid") in refused: + continue + if record.get("kind") == "episodic_node" and isinstance( + record.get("entity_edges"), list + ): + record["entity_edges"] = [ + uuid for uuid in record["entity_edges"] if uuid not in refused + ] + selected.append(record) + + record_bytes = [_json_line(record) for record in selected] + digest = hashlib.sha256(b"".join(record_bytes)).hexdigest() + decisions_digest = hashlib.sha256(decisions.read_bytes()).hexdigest() + promotion_header = dict(snapshot_header) + promotion_header.update( + created_at=datetime.now(timezone.utc).isoformat(), + record_count=len(selected), + sha256=digest, + review={ + "source_sha256": snapshot_header.get("sha256"), + "decisions_sha256": decisions_digest, + "accepted": len(expected) - len(refused), + "refused": len(refused), + }, + ) + destination = output or default_approved_path(snapshot) + _atomic_snapshot(destination, promotion_header, selected) + return { + "output": str(destination), + "source_sha256": snapshot_header.get("sha256"), + "sha256": digest, + "decisions_sha256": decisions_digest, + "accepted": len(expected) - len(refused), + "refused": len(refused), + "record_count": len(selected), + } diff --git a/src/kg_mcp/runtime.py b/src/kg_mcp/runtime.py index 3842b17..1d5a406 100644 --- a/src/kg_mcp/runtime.py +++ b/src/kg_mcp/runtime.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import copy import os from typing import Any @@ -22,6 +23,32 @@ def uses_responses_api(settings: Settings) -> bool: return _is_official_openai(settings.llm.api_url) +def require_all_schema_properties(schema: dict[str, Any]) -> dict[str, Any]: + """Return a constrained-decoding schema that emits every declared object key. + + Pydantic already represents optional values as nullable types. Grammar-backed + OpenAI-compatible servers interpret a property omitted from ``required`` as a key + they may leave out entirely, so temporal fields can disappear before validation. + Requiring the keys while preserving their nullable type keeps ``null`` distinct + from an omitted field. The input schema is never mutated. + """ + prepared = copy.deepcopy(schema) + + def visit(value: Any) -> None: + if isinstance(value, dict): + properties = value.get("properties") + if isinstance(properties, dict): + value["required"] = list(properties) + for child in value.values(): + visit(child) + elif isinstance(value, list): + for child in value: + visit(child) + + visit(prepared) + return prepared + + async def bounded(awaitable: Any, seconds: float, what: str) -> Any: """Bound one graph call so a hung backend fails loudly instead of blocking forever.""" try: @@ -113,7 +140,20 @@ def build_llm(settings: Settings): if uses_responses_api(settings): llm = OpenAIClient(config=llm_config, max_tokens=settings.llm.max_tokens) else: - llm = OpenAIGenericClient( + class GraphitiLocalGenericClient(OpenAIGenericClient): + def _build_response_format(self, response_model): + response_format = super()._build_response_format(response_model) + if ( + response_model is not None + and self.structured_output_mode == "json_schema" + and settings.llm.require_all_schema_properties + ): + response_format["json_schema"]["schema"] = require_all_schema_properties( + response_model.model_json_schema() + ) + return response_format + + llm = GraphitiLocalGenericClient( config=llm_config, max_tokens=settings.llm.max_tokens, structured_output_mode=settings.llm.structured_output_mode, @@ -128,6 +168,10 @@ def build_graphiti(settings: Settings, *, read_only: bool): from graphiti_core import Graphiti from graphiti_core.embedder.openai import OpenAIEmbedder, OpenAIEmbedderConfig + from kg_mcp.dedup import install_deterministic_tie_break + + install_deterministic_tie_break() + llm = build_llm(settings) embedder = OpenAIEmbedder( config=OpenAIEmbedderConfig( diff --git a/src/kg_mcp/server.py b/src/kg_mcp/server.py index 7d5aa57..dd76eea 100644 --- a/src/kg_mcp/server.py +++ b/src/kg_mcp/server.py @@ -241,9 +241,10 @@ async def search_nodes( try: graph, active, timeout = runtime(group_ids) groups = authorized_groups(group_ids, active) - from kg_mcp.retrieval import compatible_query + from kg_mcp.retrieval import candidate_limit, compatible_query, retrieval_stats safe_query = compatible_query(query, active) + candidates = candidate_limit(max_nodes, active) from graphiti_core.search.search_config_recipes import ( NODE_HYBRID_SEARCH_NODE_DISTANCE, NODE_HYBRID_SEARCH_RRF, @@ -252,7 +253,7 @@ async def search_nodes( recipe = ( NODE_HYBRID_SEARCH_NODE_DISTANCE if center_node_uuid else NODE_HYBRID_SEARCH_RRF - ) + ).model_copy(update={"limit": candidates}) async def find_nodes() -> list[Any]: labels = entity_types or [] @@ -293,6 +294,13 @@ async def find_nodes() -> list[Any]: return { "message": "Nodes retrieved successfully" if nodes else "No relevant nodes found", "nodes": nodes, + "retrieval": retrieval_stats( + requested=max_nodes, + candidate_ceiling=candidates, + candidates_seen=len(found), + eligible=len(found), + returned=len(nodes), + ), } except Exception as exc: logger.exception("node search failed") @@ -322,6 +330,7 @@ async def search_memory_facts( compatible_query, current_edges, rerank_edges, + retrieval_stats, ) safe_query = compatible_query(query, active) @@ -334,7 +343,7 @@ async def search_memory_facts( invalid_at=_date_range(invalid_at_after, invalid_at_before), ) - async def find_facts() -> tuple[list[tuple[Any, float]], int]: + async def find_facts() -> tuple[list[tuple[Any, float]], dict[str, Any]]: edges = await graph.search( query=safe_query, group_ids=groups, @@ -346,14 +355,22 @@ async def find_facts() -> tuple[list[tuple[Any, float]], int]: list(edges), include_invalidated=include_invalidated ) ranked = await rerank_edges(graph, safe_query, current, active, max_facts) - return ranked, suppressed + return ranked, retrieval_stats( + requested=max_facts, + candidate_ceiling=candidates, + candidates_seen=len(edges), + eligible=len(current), + returned=len(ranked), + suppressed=suppressed, + ) - ranked, suppressed = await bounded(find_facts(), timeout, "search_memory_facts") + ranked, retrieval = await bounded(find_facts(), timeout, "search_memory_facts") facts = [_edge(edge, score) for edge, score in ranked] return { "message": "Facts retrieved successfully" if facts else "No relevant facts found", "facts": facts, - "suppressed_invalidated": suppressed, + "suppressed_invalidated": retrieval["suppressed"], + "retrieval": retrieval, "ranker": active.reranker.provider, } except Exception as exc: diff --git a/tests/test_v04_release.py b/tests/test_v04_release.py index a37d8b5..83d3dc8 100644 --- a/tests/test_v04_release.py +++ b/tests/test_v04_release.py @@ -16,15 +16,17 @@ def test_mcp_registry_manifest_matches_the_python_release(): + from kg_mcp import __version__ + root = Path(__file__).resolve().parents[1] manifest = json.loads((root / "server.json").read_text(encoding="utf-8")) assert manifest["name"] == "io.github.renezander030/graphiti-local" - assert manifest["version"] == "0.4.1" + assert manifest["version"] == __version__ assert manifest["packages"] == [ { "registryType": "pypi", "identifier": "graphiti-local", - "version": "0.4.1", + "version": __version__, "transport": {"type": "stdio"}, } ] diff --git a/tests/test_v06_release.py b/tests/test_v06_release.py new file mode 100644 index 0000000..f25c980 --- /dev/null +++ b/tests/test_v06_release.py @@ -0,0 +1,352 @@ +"""The 0.6.0 contract: durable ingestion and auditable retrieval/review.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from collections import defaultdict +from pathlib import Path +from types import SimpleNamespace + +import pytest +from pydantic import BaseModel + +from kg_mcp.config import Settings + + +def _settings(**graph_overrides): + graph = { + "groups": ["example"], + "ingest_max_attempts": 3, + "ingest_retry_base_seconds": 0, + **graph_overrides, + } + return Settings.model_validate( + { + "graph": graph, + "database": {"provider": "falkordb"}, + "llm": { + "model": "test", + "api_url": "http://localhost:11434/v1", + "api_key": "local-test", + }, + "embedder": {"model": "test", "dimensions": 8}, + } + ) + + +def test_release_metadata_and_core_pin_are_in_sync(): + root = Path(__file__).resolve().parents[1] + manifest = json.loads((root / "server.json").read_text(encoding="utf-8")) + project = (root / "pyproject.toml").read_text(encoding="utf-8") + assert manifest["version"] == "0.6.0" + assert manifest["packages"][0]["version"] == "0.6.0" + assert 'version = "0.6.0"' in project + assert 'graphiti-core[falkordb]==0.30.2' in project + + +def test_chunking_is_bounded_stable_and_lossless(): + from kg_mcp.ingest import chunk_text + + text = "First paragraph has several words.\n\n" + "second " * 20 + first = chunk_text(text, 45) + second = chunk_text(text, 45) + assert first == second + assert all(0 < len(chunk) <= 45 for chunk in first) + assert " ".join(" ".join(first).split()) == " ".join(text.split()) + + +def test_document_directory_ingest_is_sorted_and_skips_hidden_files(tmp_path: Path): + from kg_mcp.ingest import read_input + + (tmp_path / "b.md").write_text("bravo", encoding="utf-8") + (tmp_path / "a.txt").write_text("alpha", encoding="utf-8") + nested = tmp_path / "nested" + nested.mkdir() + (nested / "a.txt").write_text("nested alpha", encoding="utf-8") + (tmp_path / ".hidden.md").write_text("secret", encoding="utf-8") + (tmp_path / "ignored.py").write_text("print('no')", encoding="utf-8") + records = read_input(tmp_path, domain="example") + assert [record["name"] for record in records] == ["a.txt", "b.md", "nested/a.txt"] + assert {record["domain"] for record in records} == {"example"} + + +def test_document_ingest_rejects_zero_content(tmp_path: Path): + from kg_mcp.ingest import read_input + + empty = tmp_path / "empty.md" + empty.write_text(" \n", encoding="utf-8") + with pytest.raises(ValueError, match="no text"): + read_input(empty) + + +def test_document_domain_does_not_override_an_explicit_json_group(tmp_path: Path): + from kg_mcp.ingest import read_input + + source = tmp_path / "records.json" + source.write_text( + json.dumps({"name": "record", "body": "fact", "domain": "explicit"}), + encoding="utf-8", + ) + assert read_input(source, domain="fallback")[0]["domain"] == "explicit" + + +def test_ingest_retries_with_one_stable_episode_id_and_returns_receipt(monkeypatch): + from kg_mcp.ingest import ingest_records + + calls = [] + + class Graph: + async def build_indices_and_constraints(self): + return None + + async def add_episode(self, **kwargs): + calls.append(kwargs["uuid"]) + if len(calls) < 3: + raise TimeoutError("backend timed out") + return SimpleNamespace( + episode=SimpleNamespace(uuid=kwargs["uuid"]), + nodes=[object(), object()], + edges=[object()], + ) + + async def close(self): + return None + + monkeypatch.setattr("kg_mcp.runtime.build_graphiti", lambda *a, **k: Graph()) + result = asyncio.run( + ingest_records( + [{"name": "one", "body": "fact", "domain": "example"}], + apply=True, + ledger=False, + settings=_settings(), + track_fingerprint=False, + ) + ) + assert result["ingested"] == 1 and result["failed"] == [] + assert len(set(calls)) == 1 and len(calls) == 3 + assert result["receipts"][0] == { + "key": result["receipts"][0]["key"], + "episode_uuid": calls[0], + "name": "one", + "domain": "example", + "nodes": 2, + "edges": 1, + "attempts": 3, + "ingested_at": result["receipts"][0]["ingested_at"], + } + + +def test_exhausted_ingest_failure_is_actionable(monkeypatch): + from kg_mcp.ingest import ingest_records + + class Graph: + async def build_indices_and_constraints(self): + return None + + async def add_episode(self, **kwargs): + raise TimeoutError(f"query timed out for {kwargs['uuid']}") + + async def close(self): + return None + + monkeypatch.setattr("kg_mcp.runtime.build_graphiti", lambda *a, **k: Graph()) + result = asyncio.run( + ingest_records( + [{"name": "one", "body": "fact", "domain": "example"}], + apply=True, + ledger=False, + settings=_settings(), + track_fingerprint=False, + ) + ) + failure = result["failed"][0] + assert failure["attempts"] == 3 + assert failure["retryable"] is True + assert failure["episode_uuid"] and failure["key"] + + +def test_success_and_failure_ledgers_are_durable(tmp_path: Path, monkeypatch): + from kg_mcp.ingest import FAILURES, RECEIPTS, ingest_records + + monkeypatch.setattr("kg_mcp.workspace.workspace_dir", lambda: tmp_path) + outcomes = [ + SimpleNamespace( + episode=SimpleNamespace(uuid="stored"), nodes=[object()], edges=[object()] + ), + TimeoutError("query timed out"), + ] + + class Graph: + async def build_indices_and_constraints(self): + return None + + async def add_episode(self, **kwargs): + del kwargs + outcome = outcomes.pop(0) if len(outcomes) > 1 else outcomes[0] + if isinstance(outcome, Exception): + raise outcome + return outcome + + async def close(self): + return None + + monkeypatch.setattr("kg_mcp.runtime.build_graphiti", lambda *a, **k: Graph()) + settings = _settings(ingest_max_attempts=1) + result = asyncio.run( + ingest_records( + [ + {"name": "good", "body": "land", "domain": "example"}, + {"name": "bad", "body": "fail", "domain": "example"}, + ], + apply=True, + settings=settings, + track_fingerprint=False, + ) + ) + assert result["ingested"] == 1 and len(result["failed"]) == 1 + assert json.loads((tmp_path / RECEIPTS).read_text(encoding="utf-8"))["episode_uuid"] + assert json.loads((tmp_path / FAILURES).read_text(encoding="utf-8"))["attempts"] == 1 + + +def _snapshot(path: Path) -> Path: + from kg_mcp.export import FORMAT_VERSION, _atomic_snapshot, _json_line + + records = [ + {"kind": "entity_node", "uuid": "n1", "group_id": "example"}, + { + "kind": "episodic_node", + "uuid": "ep1", + "group_id": "example", + "entity_edges": ["keep", "drop"], + }, + {"kind": "entity_edge", "uuid": "keep", "fact": "keep me"}, + {"kind": "entity_edge", "uuid": "drop", "fact": "drop me"}, + ] + digest = hashlib.sha256(b"".join(_json_line(record) for record in records)).hexdigest() + header = { + "kind": "export", + "format_version": FORMAT_VERSION, + "record_count": len(records), + "sha256": digest, + "provider": "ladybug", + "groups": ["example"], + } + _atomic_snapshot(path, header, records) + return path + + +def test_review_decisions_filter_facts_and_leave_a_digest_receipt(tmp_path: Path): + from kg_mcp.restore import read_snapshot + from kg_mcp.review import apply_review_decisions, create_decision_template + + source = _snapshot(tmp_path / "review.jsonl") + template = Path(create_decision_template(source)["output"]) + lines = [json.loads(line) for line in template.read_text(encoding="utf-8").splitlines()] + for row in lines[1:]: + row["decision"] = "accept" if row["uuid"] == "keep" else "refuse" + row["reason"] = "reviewed" + template.write_text("".join(json.dumps(row) + "\n" for row in lines), encoding="utf-8") + + receipt = apply_review_decisions(source, template) + header, records = read_snapshot(Path(receipt["output"])) + assert receipt["accepted"] == 1 and receipt["refused"] == 1 + assert {record.get("uuid") for record in records} == {"n1", "ep1", "keep"} + episode = next(record for record in records if record["kind"] == "episodic_node") + assert episode["entity_edges"] == ["keep"] + assert header["review"]["decisions_sha256"] == receipt["decisions_sha256"] + + +def test_review_refuses_unresolved_decisions(tmp_path: Path): + from kg_mcp.review import apply_review_decisions, create_decision_template + + source = _snapshot(tmp_path / "review.jsonl") + template = Path(create_decision_template(source)["output"]) + with pytest.raises(ValueError, match="unresolved"): + apply_review_decisions(source, template) + + +def test_retrieval_reports_a_reached_candidate_ceiling(): + from kg_mcp.retrieval import retrieval_stats + + stats = retrieval_stats( + requested=5, + candidate_ceiling=15, + candidates_seen=15, + eligible=4, + returned=4, + suppressed=11, + ) + assert stats["recall_may_be_incomplete"] is True + assert stats["suppressed"] == 11 + + +def test_required_schema_keeps_nullable_types_and_recurses(): + from kg_mcp.runtime import require_all_schema_properties + + schema = { + "type": "object", + "properties": { + "valid_at": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "nested": {"type": "object", "properties": {"value": {"type": "string"}}}, + }, + } + prepared = require_all_schema_properties(schema) + assert "required" not in schema + assert prepared["required"] == ["valid_at", "nested"] + assert prepared["properties"]["nested"]["required"] == ["value"] + assert {item["type"] for item in prepared["properties"]["valid_at"]["anyOf"]} == { + "string", + "null", + } + + +def test_generic_llm_uses_the_required_nullable_schema(): + from kg_mcp.runtime import build_llm + + class Result(BaseModel): + valid_at: str | None = None + + client = build_llm(_settings()) + schema = client._build_response_format(Result)["json_schema"]["schema"] + assert schema["required"] == ["valid_at"] + + +def test_dedup_tie_break_prefers_the_lexically_first_uuid(monkeypatch): + from graphiti_core.utils.maintenance import dedup_helpers as helpers + + from kg_mcp import dedup + + monkeypatch.setattr(helpers, "_FUZZY_JACCARD_THRESHOLD", 0.5) + extracted = SimpleNamespace(uuid="new", name="deterministic candidate", labels=["Entity"]) + first = SimpleNamespace(uuid="a", name="deterministic candidatf", labels=["Entity"]) + second = SimpleNamespace(uuid="z", name="deterministic candidatg", labels=["Entity"]) + shared = {"shared"} + signature = helpers._minhash_signature(shared) + buckets = defaultdict(list) + for index, band in enumerate(helpers._lsh_bands(signature)): + buckets[(index, band)] = ["z", "a"] + indexes = SimpleNamespace( + normalized_existing={}, + nodes_by_uuid={"a": first, "z": second}, + shingles_by_candidate={"a": shared, "z": shared}, + lsh_buckets=buckets, + ) + monkeypatch.setattr(helpers, "_cached_shingles", lambda value: shared) + state = SimpleNamespace( + resolved_nodes=[None], uuid_map={}, unresolved_indices=[], duplicate_pairs=[] + ) + dedup._resolve_with_similarity([extracted], indexes, state) + assert state.resolved_nodes == [first] + assert state.uuid_map == {"new": "a"} + + +def test_deterministic_resolver_is_installed_at_the_graphiti_call_site(): + from graphiti_core.utils.maintenance import dedup_helpers, node_operations + + from kg_mcp.dedup import _resolve_with_similarity, install_deterministic_tie_break + + install_deterministic_tie_break() + assert dedup_helpers._resolve_with_similarity is _resolve_with_similarity + assert node_operations._resolve_with_similarity is _resolve_with_similarity diff --git a/uv.lock b/uv.lock index c0d4b86..8a5ad01 100644 --- a/uv.lock +++ b/uv.lock @@ -5,10 +5,10 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version < '3.11' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform != 'win32'", + "python_full_version < '3.11' and sys_platform == 'win32'", "python_full_version < '3.11' and sys_platform != 'win32'", ] @@ -436,7 +436,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "sys_platform != 'win32'" }, + { name = "cuda-pathfinder" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a9/21/8464d133752951c154feafb3b65c297e7d80f301183d220bec4c830f1441/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86", size = 6073403, upload-time = "2026-05-29T23:11:36.22Z" }, @@ -471,43 +471,43 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cudart = [ - { name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cufft = [ - { name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cufile = [ - { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] curand = [ - { name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cusolver = [ - { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] [[package]] @@ -524,7 +524,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -564,7 +564,7 @@ wheels = [ [[package]] name = "graphiti-core" -version = "0.30.1" +version = "0.30.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "neo4j" }, @@ -577,9 +577,9 @@ dependencies = [ { name = "python-dotenv" }, { name = "tenacity" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/5f/c59bec30bae0bfd470baddadc15bd8af7ef92e020466c272933090453b53/graphiti_core-0.30.1.tar.gz", hash = "sha256:7391424ad1e4412e99d4e1f5751da27969c1f908c87c383cef6c933dc67d13fe", size = 6970562, upload-time = "2026-09-01T19:15:08.27Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/a1/3e0fe119b6f3116ef7671ae1b2d98ef70655ac634d9914e748b5f97ecacb/graphiti_core-0.30.2.tar.gz", hash = "sha256:b8ac6999705d350ebb53d799505d49b76f5d74c20265a09df4094645b5d8ada9", size = 6978112, upload-time = "2026-09-08T20:39:14.59Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/a7/94b06a98f7c0bbe03021316488ca55481e8d6500a0e67b21af17b9fb39b4/graphiti_core-0.30.1-py3-none-any.whl", hash = "sha256:d7fd59c6ca01cfda45fe8e2669d30272d3d083e3ed5d62f810a72310670cdec8", size = 361453, upload-time = "2026-09-01T19:15:05.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/19/7ee37826a9dff81eb4d242a785c4ca76d2d510528dd52fc7dd10fb45d9cd/graphiti_core-0.30.2-py3-none-any.whl", hash = "sha256:97674a49514130db175faecf23221ce9338240be3cbe13a7e23e5a5033892b9f", size = 363163, upload-time = "2026-09-08T20:39:12.422Z" }, ] [package.optional-dependencies] @@ -589,7 +589,7 @@ falkordb = [ [[package]] name = "graphiti-local" -version = "0.5.0" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "filelock" }, @@ -613,7 +613,7 @@ rerank = [ [package.metadata] requires-dist = [ { name = "filelock", specifier = ">=3.16,<4" }, - { name = "graphiti-core", extras = ["falkordb"], specifier = "==0.30.1" }, + { name = "graphiti-core", extras = ["falkordb"], specifier = "==0.30.2" }, { name = "ladybug", specifier = ">=0.19.1,<1" }, { name = "mcp", specifier = ">=1.27,<2" }, { name = "pydantic", specifier = ">=2.11,<3" }, @@ -1150,8 +1150,8 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } @@ -1392,7 +1392,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cuda-nvrtc" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -1431,7 +1431,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -1443,7 +1443,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -1473,9 +1473,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "sys_platform != 'win32'" }, - { name = "nvidia-cusparse", marker = "sys_platform != 'win32'" }, - { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -1487,7 +1487,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -2237,8 +2237,8 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } @@ -2418,10 +2418,10 @@ resolution-markers = [ "python_full_version < '3.11' and sys_platform != 'win32'", ] dependencies = [ - { name = "joblib", marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "threadpoolctl", marker = "python_full_version < '3.11'" }, + { name = "joblib" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } wheels = [ @@ -2465,18 +2465,18 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform != 'win32'", ] dependencies = [ - { name = "joblib", marker = "python_full_version >= '3.11'" }, - { name = "narwhals", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "threadpoolctl", marker = "python_full_version >= '3.11'" }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } wheels = [ @@ -2521,7 +2521,7 @@ resolution-markers = [ "python_full_version < '3.11' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -2581,7 +2581,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -2658,7 +2658,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } wheels = [