Merge pull request #600 from ggozad/feat/mcp-revisited
Make the MCP server read-only and multi-database; replace ask_question and analyze with execute_code; ship a Claude Code and Codex plugin with the haiku-rag skill
This commit is contained in:
commit
5cecd7b50e
39 changed files with 2562 additions and 1061 deletions
20
.agents/plugins/marketplace.json
Normal file
20
.agents/plugins/marketplace.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "haiku-rag",
|
||||
"interface": {
|
||||
"displayName": "haiku.rag"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "haiku-rag",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/haiku-rag"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
}
|
||||
]
|
||||
}
|
||||
14
.claude-plugin/marketplace.json
Normal file
14
.claude-plugin/marketplace.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "haiku-rag",
|
||||
"description": "The haiku.rag knowledge base as Claude Code tools and a skill.",
|
||||
"owner": {
|
||||
"name": "Yiorgis Gozadinos"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "haiku-rag",
|
||||
"source": "./plugins/haiku-rag",
|
||||
"description": "Search, read and analyze your haiku.rag knowledge base from Claude Code."
|
||||
}
|
||||
]
|
||||
}
|
||||
52
CHANGELOG.md
52
CHANGELOG.md
|
|
@ -2,8 +2,31 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Claude Code and Codex plugin under `plugins/haiku-rag/`: two client manifests
|
||||
sharing the server configuration and the `haiku-rag` Agent Skill.
|
||||
- MCP tool `execute_code(code, filter, sources)`: runs a program in the
|
||||
analysis sandbox over the selected documents and returns what it printed;
|
||||
one sandbox per call.
|
||||
- In the analysis sandbox, `search()` results carry `chunk_meta`,
|
||||
`list_documents()` rows and `metadata.json` carry the document `metadata`,
|
||||
and `/documents/{id}/chunks.jsonl` lists chunk ids with their metadata.
|
||||
`recovery_hint` in `haiku.rag.sandbox`.
|
||||
- MCP tools `get_document_outline` (heading tree with page numbers) and
|
||||
`get_document_section` (one section's text, subsections included), built
|
||||
on `document_items`. `build_toc` in `haiku.rag.context`.
|
||||
- MCP server `instructions`, `version`, and read-only `ToolAnnotations` on
|
||||
every tool; every parameter carries a description. `filter` on
|
||||
`search_documents` and `search_documents_by_image`. `DocumentInfo.metadata`.
|
||||
|
||||
### Changed
|
||||
|
||||
- `pydantic-monty>=0.0.23`. The analysis sandbox gains `collections`,
|
||||
`itertools`, `functools`, `dataclasses`, function decorators and
|
||||
`str.format`.
|
||||
- `fastmcp>=4.0.2,<5.0.0`, on MCP Python SDK 2. The MCP server answers both the
|
||||
session-based and the sessionless (2026-07-28) protocol.
|
||||
- Default models are `ollama:qwen3.8`: `ModelConfig`, `qa.model`,
|
||||
`processing.title_model` (was `ollama:gpt-oss`) and
|
||||
`processing.conversion_options.picture_description.model` (was
|
||||
|
|
@ -16,6 +39,35 @@
|
|||
- `processing.conversion_options.picture_description.model` defaults to
|
||||
`enable_thinking: false`, and the field now reaches the VLM: docling's
|
||||
picture-description request carries `reasoning_effort` in `params`.
|
||||
- MCP `search_documents` and `search_documents_by_image` expand results to
|
||||
their section (`HaikuRAG.expand_context`) and return the agent rendering
|
||||
as text (rank, `Document ID`, `Collection` over several databases, title,
|
||||
headings, the matched chunk's metadata, passage) and pictures as
|
||||
`ImageContent` blocks, with no structured content.
|
||||
`SearchResult.format_for_agent(include_document_id=, include_chunk_meta=)`;
|
||||
`collect_pictures` in `haiku.rag.tools.search`.
|
||||
- MCP tools raise on failure, with the error's message; an empty result no
|
||||
longer doubles as an error.
|
||||
- `haiku-rag mcp` covers the configured `lancedb.databases` set. `sources` on
|
||||
`search_documents`, `search_documents_by_image` and `execute_code`; `source`
|
||||
on `get_document`; an unknown name is a tool error. `DocumentInfo.source`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `toc.json` `item_range` in the analysis sandbox is a line slice into
|
||||
`items.jsonl`, as documented; it held item positions.
|
||||
- Past `analysis.code_timeout` a sandbox program starts no further host call.
|
||||
Files served from memory and in-code `search()` / `list_documents()` were
|
||||
not checked against the deadline.
|
||||
|
||||
### Removed
|
||||
|
||||
- MCP tools `ask_question` and `analyze`.
|
||||
- `format_citations` in `haiku.rag.utils`; `format_citations_rich` stays.
|
||||
- MCP write tools `add_document_from_file`, `add_document_from_url`,
|
||||
`add_document_from_text` and `delete_document`. The server opens the
|
||||
database read-only; ingest with `haiku-rag add`, `add-src`, `delete` or
|
||||
`haiku-ingester`. `create_mcp_server` loses `read_only`.
|
||||
|
||||
## [0.82.1] - 2026-09-03
|
||||
|
||||
|
|
|
|||
20
README.md
20
README.md
|
|
@ -16,7 +16,7 @@ Built on [LanceDB](https://lancedb.com/), [Pydantic AI](https://ai.pydantic.dev/
|
|||
- **Hybrid search** — Vector + full-text with Reciprocal Rank Fusion
|
||||
- **Multimodal & cross-modal search** — Multimodal embedders (vLLM, VoyageAI, Cohere) put picture vectors in the same space as text; supports text-as-query → figure hits and image-as-query
|
||||
- **Question answering** — RAG capability with citations (page numbers, section headings)
|
||||
- **Vision QA** — Vision-capable models receive figure bytes alongside chunk text; attach your own images to questions in `ask`, `analyze`, MCP, and the chat TUI
|
||||
- **Vision QA** — Vision-capable models receive figure bytes alongside chunk text; attach your own images to questions in `ask`, `analyze` and the chat TUI
|
||||
- **Reranking** — local cross-encoders, Cohere, Zero Entropy, or vLLM
|
||||
- **Analysis capability** — Complex analytical tasks via sandboxed Python code execution (aggregation, computation, multi-document analysis)
|
||||
- **Evidence compaction** — Optional capability that replaces earlier questions' search results on the request with the evidence they cited, so long conversations stop resending everything they retrieved
|
||||
|
|
@ -110,12 +110,26 @@ For direct agent composition, see the [capabilities documentation](https://ggoza
|
|||
|
||||
## MCP Server
|
||||
|
||||
Use with AI assistants like Claude Desktop:
|
||||
Use with AI assistants like Claude Code, Codex, and Claude Desktop:
|
||||
|
||||
```bash
|
||||
haiku-rag mcp --stdio
|
||||
```
|
||||
|
||||
In Claude Code, install the plugin, which registers the server and a skill:
|
||||
|
||||
```bash
|
||||
claude plugin marketplace add ggozad/haiku.rag
|
||||
claude plugin install haiku-rag
|
||||
```
|
||||
|
||||
In Codex, install the same plugin from its marketplace:
|
||||
|
||||
```bash
|
||||
codex plugin marketplace add ggozad/haiku.rag
|
||||
codex plugin add haiku-rag@haiku-rag
|
||||
```
|
||||
|
||||
Add to your Claude Desktop configuration:
|
||||
|
||||
```json
|
||||
|
|
@ -129,7 +143,7 @@ Add to your Claude Desktop configuration:
|
|||
}
|
||||
```
|
||||
|
||||
Provides tools for document management, search, QA, and analysis directly in your AI assistant.
|
||||
Provides search, document reading, and analysis tools directly in your AI assistant.
|
||||
|
||||
## Examples
|
||||
|
||||
|
|
|
|||
|
|
@ -40,4 +40,4 @@ EXPOSE 8001 8765
|
|||
|
||||
# Default command: read-only MCP server. The companion ingester service is
|
||||
# launched via docker-compose against the same image.
|
||||
CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "--read-only", "mcp", "--port", "8001"]
|
||||
CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "mcp", "--port", "8001"]
|
||||
|
|
|
|||
|
|
@ -39,4 +39,4 @@ EXPOSE 8001 8765
|
|||
|
||||
# Default command: read-only MCP server. The companion ingester service is
|
||||
# launched via docker-compose against the same image.
|
||||
CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "--read-only", "mcp", "--port", "8001"]
|
||||
CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "mcp", "--port", "8001"]
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ When `qa.max_searches` or `analysis.max_executions` runs out, the exhausted tool
|
|||
| `analysis_execute_code(code)` | Run Python against the virtual document filesystem. |
|
||||
| `analysis_cite(chunk_ids)` | Register retrieved or filesystem-derived chunk IDs. |
|
||||
|
||||
The sandbox exposes documents under `/documents/{document_id}/` with `metadata.json`, `content.txt`, `items.jsonl`, and `toc.json`.
|
||||
The sandbox exposes documents under `/documents/{document_id}/` with `metadata.json`, `content.txt`, `items.jsonl`, `chunks.jsonl` (chunk ids with their metadata) and `toc.json`. In code, `await search()` results carry `chunk_meta` and `await list_documents()` rows carry `metadata`. The interpreter's limits and the per-call budgets are listed under [MCP, Code](../mcp.md#code).
|
||||
|
||||
## Compose an agent
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ The `haiku-rag` CLI provides complete document management functionality.
|
|||
haiku-rag add -h
|
||||
```
|
||||
|
||||
With `lancedb.databases` configured, `search`, `ask`, `analyze`, and `chat` use the full set by default. Select one database for other commands with `--db-name` or `--db`. `settings`, `init-config`, and `download-models` do not open a database. See [Multiple Databases](configuration/storage.md#multiple-databases).
|
||||
With `lancedb.databases` configured, `search`, `ask`, `analyze`, `chat`, and `mcp` use the full set by default. Select one database for other commands with `--db-name` or `--db`. `settings`, `init-config`, and `download-models` do not open a database. See [Multiple Databases](configuration/storage.md#multiple-databases).
|
||||
|
||||
## Document Management
|
||||
|
||||
|
|
@ -477,9 +477,6 @@ haiku-rag mcp --port 9000
|
|||
|
||||
# Bind to all interfaces (containers, trusted LAN)
|
||||
haiku-rag mcp --host 0.0.0.0
|
||||
|
||||
# Read-only mode (no write tools)
|
||||
haiku-rag --read-only mcp
|
||||
```
|
||||
|
||||
See [MCP](mcp.md) for details. For continuous document ingestion
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ Context expansion is automatic and section-aware. For structured documents (with
|
|||
|
||||
## Question Answering Configuration
|
||||
|
||||
Configure the RAG capability (used by `client.ask`, `haiku-rag ask`, and the MCP `ask_question` tool):
|
||||
Configure the RAG capability (used by `client.ask` and `haiku-rag ask`):
|
||||
|
||||
```yaml
|
||||
qa:
|
||||
|
|
@ -50,13 +50,13 @@ analysis:
|
|||
provider: anthropic
|
||||
name: claude-sonnet-4-20250514
|
||||
temperature: 0.0 # Default: 0.0 (deterministic for code generation)
|
||||
code_timeout: 60.0 # Max seconds a call may spend reading documents
|
||||
code_timeout: 60.0 # Per call: compute stops, no read or search starts past it
|
||||
max_output_chars: 50000 # Truncate output after this many chars
|
||||
max_executions: 15 # Max execute_code calls per question
|
||||
```
|
||||
|
||||
- **model**: LLM configuration (see [Providers](providers.md#model-settings)). When unset, falls back to `qa.model`.
|
||||
- **code_timeout**: Seconds a single `execute_code` call may spend reading documents (default: 60). The sandbox refuses further reads past this point. Code that computes without reading is killed by the worker watchdog at the same limit. `code_timeout * max_executions` is the cumulative ceiling across all calls in one question.
|
||||
- **code_timeout**: Seconds a single `execute_code` call has (default: 60). Past it the sandbox starts no further host call, a document read or an in-code `search()` / `list_documents()`; one already running finishes. Code that computes without host calls is killed by the worker watchdog at the same limit. `code_timeout * max_executions` is the cumulative ceiling across all calls in one question.
|
||||
- **max_output_chars**: Truncate code output after this many characters (default: 50000)
|
||||
- **max_executions**: Maximum `execute_code` calls per question before the capability is told to answer from what it has (default: 15)
|
||||
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ writing process per database URI, any number of read-only consumers.
|
|||
The recommended layout for production is "different buckets, same account, separate IAM roles per process":
|
||||
|
||||
- **Ingestion process** — IAM role with `s3:Get/List` on the documents bucket and `s3:Get/Put/Delete` on the LanceDB bucket. Runs `haiku-ingester serve` (with `ingester.sources[type=s3]` pointing at the documents bucket). Exactly one such process per LanceDB URI.
|
||||
- **Consumer processes** (1..N) — IAM role with `s3:Get/List` on the LanceDB bucket only. Run `haiku-rag --read-only mcp`, the chat TUI, etc. They never see the documents bucket.
|
||||
- **Consumer processes** (1..N) — IAM role with `s3:Get/List` on the LanceDB bucket only. Run `haiku-rag mcp`, the chat TUI, etc. They never see the documents bucket.
|
||||
|
||||
Each process picks up its own credentials from the AWS default chain (env vars, IAM instance role, AWS profile), so no credentials are hard-coded in the configuration files.
|
||||
|
||||
|
|
@ -281,9 +281,9 @@ Conversion, chunking, and title generation do not access a database and remain a
|
|||
|
||||
Commands use database sets as follows:
|
||||
|
||||
- **Set-capable**: `search`, `ask`, `analyze`, and `chat` use the full configured set, or the single database selected by `--db-name`.
|
||||
- **Set-capable**: `search`, `ask`, `analyze`, `chat`, and `mcp` use the full configured set, or the single database selected by `--db-name`.
|
||||
- **Config-only**: `settings`, `init-config`, and `download-models` do not open a database.
|
||||
- **Single-database**: everything else — document writes, `rebuild`, `vacuum`, `migrate`, `init`, `info`, `history`, `tag`, `doctor`, `list`, `inspect`, `visualize`, and `mcp` — works on one database, selected with the global `--db-name` option.
|
||||
- **Single-database**: everything else — document writes, `rebuild`, `vacuum`, `migrate`, `init`, `info`, `history`, `tag`, `doctor`, `list`, `inspect`, and `visualize` — works on one database, selected with the global `--db-name` option.
|
||||
|
||||
```bash
|
||||
haiku-rag search "query" # every configured database
|
||||
|
|
|
|||
180
docs/mcp.md
180
docs/mcp.md
|
|
@ -19,15 +19,71 @@ haiku-rag mcp --host 0.0.0.0 --port 8001
|
|||
# stdio transport (for Claude Desktop)
|
||||
haiku-rag mcp --stdio
|
||||
|
||||
# Read-only mode (excludes write tools)
|
||||
haiku-rag --read-only mcp --stdio
|
||||
```
|
||||
|
||||
`--host` defaults to `127.0.0.1` (loopback only). Bind to `0.0.0.0` only
|
||||
when you want the MCP server reachable from outside the local machine —
|
||||
e.g. inside a Docker container with port mapping, or on a trusted LAN.
|
||||
|
||||
**Read-only mode:** When `--read-only` is specified, write tools (`add_document_from_file`, `add_document_from_url`, `add_document_from_text`, `delete_document`) are not registered. Only search and query tools remain available.
|
||||
The server opens the database read-only. Ingestion goes through the CLI
|
||||
(`haiku-rag add`, `add-src`, `delete`) or [`haiku-ingester`](ingester.md).
|
||||
|
||||
## Collections
|
||||
|
||||
With several databases in `lancedb.databases`, the server covers all of
|
||||
them, as `haiku-rag search` does. Results and documents name theirs in
|
||||
`source`. `sources` on `search_documents`, `search_documents_by_image`
|
||||
and `execute_code` restricts a call to a subset; `source` on `get_document` names the database holding the
|
||||
document. A name the server does not cover is an error.
|
||||
`haiku-rag --db-name NAME mcp` serves one. See
|
||||
[Multiple Databases](configuration/storage.md#multiple-databases).
|
||||
|
||||
## Claude Code
|
||||
|
||||
The repository ships a plugin that registers the server and a skill telling
|
||||
Claude when and how to use it:
|
||||
|
||||
```bash
|
||||
claude plugin marketplace add ggozad/haiku.rag
|
||||
claude plugin install haiku-rag
|
||||
```
|
||||
|
||||
The plugin runs `haiku-rag mcp --stdio`, so `haiku-rag` must be on the PATH
|
||||
and the configuration decides the database. The skill pre-approves every tool
|
||||
and is also invocable as `/haiku-rag`. To register the server without the
|
||||
plugin:
|
||||
|
||||
```bash
|
||||
claude mcp add haiku-rag -- haiku-rag mcp --stdio
|
||||
```
|
||||
|
||||
The skill works with that registration too: copy `plugins/haiku-rag/skills/haiku-rag`
|
||||
into `~/.claude/skills/` and change the tool prefix in its `allowed-tools` from
|
||||
`mcp__plugin_haiku-rag_haiku-rag__` to `mcp__haiku-rag__`.
|
||||
|
||||
## Codex
|
||||
|
||||
The repository's Codex plugin registers the server and installs the same Agent
|
||||
Skill:
|
||||
|
||||
```bash
|
||||
codex plugin marketplace add ggozad/haiku.rag
|
||||
codex plugin add haiku-rag@haiku-rag
|
||||
```
|
||||
|
||||
The plugin runs `haiku-rag mcp --stdio`, so `haiku-rag` must be on the PATH.
|
||||
Invoke the skill as `$haiku-rag`. Codex can also select it automatically from
|
||||
its description. To register the server without the plugin:
|
||||
|
||||
```bash
|
||||
codex mcp add haiku-rag -- haiku-rag mcp --stdio
|
||||
```
|
||||
|
||||
The skill works with that registration too: copy
|
||||
`plugins/haiku-rag/skills/haiku-rag` into `~/.agents/skills/`.
|
||||
The `allowed-tools` field supplies Claude Code's tool pre-approval and may be
|
||||
ignored by other Agent Skills clients. Codex configures MCP tool approvals
|
||||
separately in `config.toml`.
|
||||
|
||||
## Claude Desktop Integration
|
||||
|
||||
|
|
@ -57,63 +113,95 @@ With a custom database path:
|
|||
}
|
||||
```
|
||||
|
||||
After restarting Claude Desktop, you can ask Claude to search your documents, add new content, or answer questions using your knowledge base.
|
||||
After restarting Claude Desktop, you can ask Claude to search your documents or answer questions using your knowledge base.
|
||||
|
||||
## Available Tools
|
||||
## Tools
|
||||
|
||||
### Document Management
|
||||
Every tool is read-only and says so in its annotations. Each parameter carries
|
||||
a description in the tool schema, so the listing below names them without
|
||||
repeating it.
|
||||
|
||||
- **`add_document_from_file`** - Add documents from local file paths
|
||||
- `file_path` (required): Path to the file
|
||||
- `metadata` (optional): Key-value metadata
|
||||
- `title` (optional): Human-readable title
|
||||
| Tool | Registered | Parameters |
|
||||
|---|---|---|
|
||||
| `search_documents` | always | `query`, `limit`, `include_images`, `filter`, `sources` |
|
||||
| `search_documents_by_image` | multimodal embedder only | `image_base64`, `limit`, `include_images`, `filter`, `sources` |
|
||||
| `get_document` | always | `document_id`, `source` |
|
||||
| `get_document_outline` | always | `document_id`, `source` |
|
||||
| `get_document_section` | always | `document_id`, `section_id`, `source` |
|
||||
| `list_documents` | always | `limit`, `offset`, `filter` |
|
||||
| `execute_code` | always | `code`, `filter`, `sources` |
|
||||
|
||||
- **`add_document_from_url`** - Add documents from URLs
|
||||
- `url` (required): URL to fetch
|
||||
- `metadata` (optional): Key-value metadata
|
||||
- `title` (optional): Human-readable title
|
||||
`search_documents` runs hybrid search, vector and full-text. Its text content
|
||||
is the rendering the in-process agents read: results best first, each with its
|
||||
rank, `Document ID`, `Collection` when the server covers several, the document
|
||||
title, section headings, the matched chunk's metadata when it has any, and the
|
||||
passage expanded to its section the way the agents get it
|
||||
(`search.max_context_chars` caps it). Pictures in the results follow as
|
||||
image blocks, one per distinct picture, each preceded by a line naming its
|
||||
result; `include_images: false` leaves them out. Search results carry no
|
||||
structured content, so every client shows the model the same text and
|
||||
images. Scores are not comparable across
|
||||
queries or search types, so rank is the signal. `search_documents_by_image`
|
||||
embeds the query image and searches by vector similarity alone.
|
||||
|
||||
- **`add_document_from_text`** - Add documents from raw text content
|
||||
- `content` (required): Text content
|
||||
- `uri` (optional): URI identifier
|
||||
- `metadata` (optional): Key-value metadata
|
||||
- `title` (optional): Human-readable title
|
||||
`get_document` returns a document whole, in reading order. For a long one,
|
||||
`get_document_outline` returns the heading tree with page numbers and
|
||||
`get_document_section` the text of one section, subsections included; a
|
||||
node's `id` in the outline is the `section_id`. A document without headings
|
||||
has an empty outline. `list_documents` returns titles, URIs and metadata,
|
||||
which is how a client learns what a filter can match.
|
||||
|
||||
- **`get_document`** - Retrieve a document by ID
|
||||
- `document_id` (required): The document ID
|
||||
### Code
|
||||
|
||||
- **`list_documents`** - List documents with pagination and filtering
|
||||
- `limit` (optional): Maximum number to return
|
||||
- `offset` (optional): Number to skip
|
||||
- `filter` (optional): SQL WHERE clause for filtering
|
||||
`execute_code` runs a Python program in the sandbox of the
|
||||
[analysis capability](capabilities/analysis.md), over the documents `filter`
|
||||
and `sources` select, and returns what it printed. The program reads
|
||||
`/documents/{document_id}/` (`metadata.json`, `content.txt`, `items.jsonl`,
|
||||
`chunks.jsonl`, `toc.json`) and can `await search()` and
|
||||
`await list_documents()`; the tool description spells out the fields and the
|
||||
patterns that matter. Each call is one program: nothing carries over between
|
||||
calls, and the sandbox is created and closed per call. A failing program is a
|
||||
tool error carrying the interpreter's message and any output printed before
|
||||
it. No model runs on the server. Claude Code moves a call still running after
|
||||
about two minutes to a background task.
|
||||
|
||||
- **`delete_document`** - Delete a document by ID
|
||||
- `document_id` (required): The document ID
|
||||
The interpreter is [Monty](https://github.com/pydantic/monty), a Python subset.
|
||||
Useful modules include `json`, `re`, `math`, `pathlib`, `datetime`,
|
||||
`collections`, `itertools`, `functools` and `dataclasses`. Absent, and often
|
||||
reached for: `decimal` and `statistics`. No generator functions, class
|
||||
inheritance or `match` statements, and a file object cannot be iterated. Files are read-only, and
|
||||
there is no network and no filesystem
|
||||
beyond `/documents`. `analysis.code_timeout` is the call's budget: compute is
|
||||
stopped at it, and past it no further host call starts, a file read or an
|
||||
in-code search alike, though one already running finishes.
|
||||
`analysis.max_output_chars` bounds the output.
|
||||
|
||||
### Search
|
||||
### Filters
|
||||
|
||||
- **`search_documents`** - Search using hybrid search (vector + full-text)
|
||||
- `query` (required): Search query
|
||||
- `limit` (optional): Maximum results (uses config default if not specified)
|
||||
- `include_images` (optional, default `true`): Attach base64-encoded picture bytes to picture-labeled results
|
||||
`filter` is a SQL WHERE clause over the document columns `id`, `uri`, `title`,
|
||||
`metadata`, `created_at`, `updated_at`. `metadata` is a JSON string, so match
|
||||
its keys with LIKE:
|
||||
|
||||
- **`search_documents_by_image`** - Search using an image as the query (registered only when the configured embedder supports images)
|
||||
- `image_base64` (required): Base64-encoded image (PNG/JPEG bytes)
|
||||
- `limit` (optional): Maximum results
|
||||
- `include_images` (optional, default `true`)
|
||||
```sql
|
||||
metadata LIKE '%"author": "Smith"%'
|
||||
uri LIKE '%.pdf'
|
||||
title = 'Q3 report'
|
||||
```
|
||||
|
||||
### Question Answering
|
||||
### Errors
|
||||
|
||||
- **`ask_question`** - Ask questions about your documents
|
||||
- `question` (required): The question to ask
|
||||
- `cite` (optional): Include source citations (default: false)
|
||||
- `images_base64` (optional): Base64-encoded images attached to the question (requires a vision-capable QA model)
|
||||
A failure is an MCP error carrying its message, never an empty result: a
|
||||
document or section id that matches nothing, a collection the server does not
|
||||
cover, a filter the query engine rejects, invalid base64, a program that fails
|
||||
in `execute_code` with the error it hit, and anything unexpected with its own
|
||||
message.
|
||||
|
||||
- **`analyze`** - Answer complex analytical questions via code execution
|
||||
- `question` (required): The question to answer
|
||||
- `filter` (optional): SQL WHERE clause to restrict document access
|
||||
- `images_base64` (optional): Base64-encoded images attached to the question (requires a vision-capable analysis model)
|
||||
- Best for aggregation, computation, and multi-document analysis
|
||||
### Instructions
|
||||
|
||||
The server publishes `instructions` describing the knowledge base: what it
|
||||
holds, when to reach for it, the collection names when it covers several, and
|
||||
`prompts.domain_preamble` when set. Claude Code and Codex show them to the
|
||||
model. Claude Desktop does not, so every tool description stands on its own.
|
||||
|
||||
## Continuous ingestion
|
||||
|
||||
|
|
|
|||
|
|
@ -103,7 +103,6 @@ services:
|
|||
"haiku-rag",
|
||||
"--config",
|
||||
"/app/haiku.rag.yaml",
|
||||
"--read-only",
|
||||
"mcp",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
|
|
|
|||
|
|
@ -934,7 +934,7 @@ class HaikuRAGApp:
|
|||
# The resolved scope: a path overrides a configured URI, and a derived
|
||||
# single-database configuration drops the name results and citations
|
||||
# carry.
|
||||
server = _mcp_server_covering(self.scope, self.config, self.read_only)
|
||||
server = _mcp_server_covering(self.scope, self.config)
|
||||
try:
|
||||
if transport == "stdio":
|
||||
await server.run_stdio_async()
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from haiku.rag.capabilities._base import (
|
|||
)
|
||||
from haiku.rag.capabilities._tools import merge_results
|
||||
from haiku.rag.config.models import AppConfig
|
||||
from haiku.rag.sandbox import AnalysisContext, Sandbox
|
||||
from haiku.rag.sandbox import AnalysisContext, Sandbox, recovery_hint
|
||||
|
||||
STATE_NAMESPACE = "analysis"
|
||||
_CAPABILITY_ID = "haiku-rag-analysis"
|
||||
|
|
@ -49,21 +49,6 @@ def multiple_collections_instructions() -> str:
|
|||
return _multiple_collections_path.read_text().rstrip()
|
||||
|
||||
|
||||
def _recovery_hint(stderr: str) -> str:
|
||||
"""Name the workaround for sandbox limits models trip over repeatedly.
|
||||
|
||||
The instructions already say file objects are not iterable, and models write
|
||||
``for line in open(...)`` regardless. Carrying the fix in the error gives
|
||||
them something to act on for the retry.
|
||||
"""
|
||||
if "TextIOWrapper" in stderr and "not iterable" in stderr:
|
||||
return (
|
||||
"\n\nHint: file objects cannot be iterated here. Read lines with "
|
||||
'.readlines() or .read().split("\\n").'
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalysisCapability(RAGCapabilityBase[AnalysisState]):
|
||||
"""Deferred capability for sandboxed computation over a RAG corpus."""
|
||||
|
|
@ -139,7 +124,7 @@ class AnalysisCapability(RAGCapabilityBase[AnalysisState]):
|
|||
)
|
||||
if not result.success:
|
||||
raise ToolFailed(
|
||||
f"{result.stderr}{_recovery_hint(result.stderr)}"
|
||||
f"{result.stderr}{recovery_hint(result.stderr)}"
|
||||
f"\n\nOutput: {result.stdout}"
|
||||
)
|
||||
return result.stdout or "No output."
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@ You can mix the two. The rule: always call `analysis_cite` before answering —
|
|||
Execute Python code in a sandboxed interpreter. Variables persist between calls — you can build state incrementally. Use `print()` to output results.
|
||||
|
||||
Inside the code, these functions are available (use `await`):
|
||||
- `await search(query, limit=10)` → list of dicts with keys: chunk_id, content, document_id, document_title, document_uri, score, page_numbers, headings, doc_item_refs, labels, picture_refs (subset of doc_item_refs labeled `picture`)
|
||||
- `await list_documents()` → list of dicts with keys: id, title, uri, created_at
|
||||
- `await search(query, limit=10)` → list of dicts with keys: chunk_id, content, document_id, document_title, document_uri, score, page_numbers, headings, doc_item_refs, labels, picture_refs (subset of doc_item_refs labeled `picture`), chunk_meta (the matched chunk's stored metadata, custom keys included)
|
||||
- `await list_documents()` → list of dicts with keys: id, title, uri, created_at, metadata
|
||||
|
||||
Available modules: `json`, `re`, `math`, `pathlib`
|
||||
Not supported: class inheritance and metaclasses, generators/yield, match statements, decorators, `collections`, iterating a file object (`for line in f`)
|
||||
Useful modules include `json`, `re`, `math`, `pathlib`, `datetime`, `collections`, `itertools`, `functools` and `dataclasses`. `decimal` and `statistics` do not exist.
|
||||
Not supported: class inheritance and metaclasses, generators/yield, match statements, iterating a file object (`for line in f`)
|
||||
|
||||
### analysis_search
|
||||
Search the knowledge base directly (outside code execution). Each result has a `Type:` (paragraph, table, code, list_item, picture). When the Type is `picture`, the corresponding figure may also be attached to the tool response as an image alongside the text — use it directly to answer questions about figures, diagrams, charts, screenshots.
|
||||
|
|
@ -39,16 +39,17 @@ All documents are mounted as a virtual filesystem at `/documents/`:
|
|||
|
||||
```
|
||||
/documents/{document_id}/
|
||||
metadata.json # {"id", "title", "uri", "created_at"}
|
||||
metadata.json # {"id", "title", "uri", "created_at", "metadata"}
|
||||
content.txt # Full document text
|
||||
items.jsonl # Structured items (one JSON object per line)
|
||||
chunks.jsonl # Chunks in order with their metadata (one JSON object per line)
|
||||
toc.json # Section tree derived from heading_level
|
||||
```
|
||||
|
||||
`{document_id}` is an internal identifier, not the user-facing `uri` (filename, URL, etc.). When you only know a document by its URI or title, use `await list_documents()` to enumerate ids and match against `uri` / `title` — that's a single call to the host. Iterating `/documents/` and reading every `metadata.json` works too but is much slower on portal-scale corpora.
|
||||
|
||||
### Reading files
|
||||
Read with `Path.read_text()` or `open()` (including `with` blocks); file objects support `.read()`, `.readline()`, and `.readlines()`. A file object cannot be iterated, so read line-wise with `.readlines()` or `.read().split("\n")` instead of `for line in f`. Files are read-only; writing raises `PermissionError`.
|
||||
Read with `Path.read_text()` or `open()` (including `with` blocks); file objects support `.read()`, `.readline()`, and `.readlines()`. A file object cannot be iterated, so read line-wise with `.readlines()` or `.read().split("\n")` instead of `for line in f`. Files are read-only; writing raises `PermissionError`. There is no network. A call has a time limit, named in the error when it is hit, and output past a size is cut with an `... (output truncated)` marker.
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
|
@ -70,7 +71,7 @@ for line in Path(f'/documents/{doc_id}/items.jsonl').read_text().strip().split("
|
|||
```
|
||||
|
||||
### metadata.json
|
||||
Document metadata: `id`, `title`, `uri`, `created_at`.
|
||||
Document metadata: `id`, `title`, `uri`, `created_at`, and `metadata`, the keys stored with the document.
|
||||
|
||||
### content.txt
|
||||
Full text content. Use for regex or keyword search across a whole document.
|
||||
|
|
@ -86,6 +87,9 @@ Each row carries:
|
|||
- `chunk_ids`: chunks that contain this item — pass to `analysis_cite()` to ground an answer that read this item directly
|
||||
- `heading_level`: H-level for `section_header` rows; `0` on non-header rows
|
||||
|
||||
### chunks.jsonl
|
||||
The document's chunks in order, one JSON object per line: `chunk_id` and `metadata`, the chunk's stored metadata (`doc_item_refs`, `headings`, `labels`, `page_numbers`, and any custom keys such as paragraph or footnote numbers). To read by chunk metadata, keep the matching rows and take the `items.jsonl` rows whose `chunk_ids` name them.
|
||||
|
||||
### toc.json
|
||||
Section tree derived from `heading_level`: `{"doc_id", "title", "tree": [...]}` where each node has `{self_ref, level, title, page_numbers, item_range: [start, end_exclusive], chunk_ids, children}`. `item_range` is a line slice into `items.jsonl` — `items[start:end]`. `chunk_ids` aggregates the citable chunks across all items in the section — pass directly to `analysis_cite()` to ground a section-scoped answer without a corpus-wide `search()` call. `tree: []` for docs with no headers.
|
||||
|
||||
|
|
@ -112,6 +116,6 @@ You MUST call `analysis_cite` before producing your final answer, every time, wi
|
|||
- Use `print()` to output results — the output is your only feedback
|
||||
- When you write code, execute it — don't describe what code would do. But not every question needs code; simple lookups are best answered by `analysis_search → analysis_cite`.
|
||||
- Use `await` for all async functions inside `analysis_execute_code` (`search`, `list_documents`)
|
||||
- Read files with `Path.read_text()` or `open()`/`with`. For lines use `.readlines()` or `.read().split("\n")`, never `for line in f`. The `collections` module is unavailable.
|
||||
- Read files with `Path.read_text()` or `open()`/`with`. For lines use `.readlines()` or `.read().split("\n")`, never `for line in f`.
|
||||
- Do NOT include chunk IDs or UUIDs in your answer text — your answer should read naturally. Use the `analysis_cite` tool separately to register citations. `cite{...}` markdown-style inline references do nothing; only an actual `analysis_cite` tool call registers a citation.
|
||||
- **Before you write your final answer, invoke the `analysis_cite` tool with the supporting chunk_ids, or with an empty list if there are none.** This is the last tool call before answering, every time.
|
||||
|
|
|
|||
|
|
@ -886,7 +886,7 @@ def mcp(
|
|||
),
|
||||
) -> None:
|
||||
"""Run the MCP server."""
|
||||
app = create_app(db)
|
||||
app = create_app(db, covers_set=True)
|
||||
|
||||
transport = "stdio" if stdio else None
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ In both cases:
|
|||
- Results without doc_item_refs pass through unexpanded
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from haiku.rag.store.models.chunk import SearchResult
|
||||
from haiku.rag.store.models.document_item import DocumentItem
|
||||
|
||||
|
|
@ -488,3 +490,77 @@ def expand_with_items(
|
|||
final_results.append(built)
|
||||
|
||||
return final_results + passthrough
|
||||
|
||||
|
||||
def build_toc(
|
||||
items: list["DocumentItem"],
|
||||
chunk_index: dict[str, list[str]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build a nested section tree from items in position order.
|
||||
|
||||
Each ``section_header`` with ``heading_level > 0`` becomes a node. Nesting
|
||||
follows the explicit levels: a header pops the stack until the top is at
|
||||
a strictly shallower level, then becomes a child of that top (or a root).
|
||||
|
||||
``item_range = [start, end_exclusive]`` indexes the position-ordered item
|
||||
list, which is the line numbering of the sandbox's ``items.jsonl``: ``start``
|
||||
is the header's index and ``end_exclusive`` the index of the next header
|
||||
whose level is the same or shallower (the next sibling or ancestor that
|
||||
ends this section), or the item count if no such header exists. Indices,
|
||||
not positions: positions may have gaps.
|
||||
|
||||
``chunk_ids`` aggregates the chunks covered by all items in the section's
|
||||
``item_range`` (deduped, order preserved). Pass directly to ``cite()`` to
|
||||
ground a section-scoped answer without a corpus-wide ``search()`` call.
|
||||
|
||||
Items without a section_header label (or with ``heading_level == 0``) are
|
||||
skipped. When all section_headers carry the same level the output is a
|
||||
flat sibling list (see docling-project/docling#2121 for an upstream case
|
||||
where every PDF section_header is emitted at level=1).
|
||||
"""
|
||||
# Defensive: every consumer is supposed to pass items in position order,
|
||||
# but the end_exclusive lookahead below silently miscomputes section
|
||||
# boundaries if it's not — better to sort once than trust the caller.
|
||||
items = sorted(items, key=lambda i: i.position)
|
||||
header_indices = [
|
||||
idx
|
||||
for idx, i in enumerate(items)
|
||||
if i.label == "section_header" and i.heading_level > 0
|
||||
]
|
||||
if not header_indices:
|
||||
return []
|
||||
|
||||
ends: list[int] = []
|
||||
for n, idx in enumerate(header_indices):
|
||||
end = len(items)
|
||||
for later in header_indices[n + 1 :]:
|
||||
if items[later].heading_level <= items[idx].heading_level:
|
||||
end = later
|
||||
break
|
||||
ends.append(end)
|
||||
|
||||
roots: list[dict[str, Any]] = []
|
||||
stack: list[tuple[int, dict[str, Any]]] = []
|
||||
for idx, end in zip(header_indices, ends, strict=True):
|
||||
h = items[idx]
|
||||
seen: set[str] = set()
|
||||
chunk_ids: list[str] = []
|
||||
for item in items[idx:end]:
|
||||
for cid in chunk_index.get(item.self_ref, []):
|
||||
if cid not in seen:
|
||||
seen.add(cid)
|
||||
chunk_ids.append(cid)
|
||||
node: dict[str, Any] = {
|
||||
"self_ref": h.self_ref,
|
||||
"level": h.heading_level,
|
||||
"title": h.text,
|
||||
"page_numbers": list(h.page_numbers),
|
||||
"item_range": [idx, end],
|
||||
"chunk_ids": chunk_ids,
|
||||
"children": [],
|
||||
}
|
||||
while stack and stack[-1][0] >= h.heading_level:
|
||||
stack.pop()
|
||||
(stack[-1][1]["children"] if stack else roots).append(node)
|
||||
stack.append((h.heading_level, node))
|
||||
return roots
|
||||
|
|
|
|||
|
|
@ -1,66 +1,168 @@
|
|||
import asyncio
|
||||
import base64
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from importlib import metadata
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Annotated
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
from fastmcp.tools import ToolResult
|
||||
from mcp.types import ContentBlock, ImageContent, TextContent, ToolAnnotations
|
||||
from pydantic import Field
|
||||
|
||||
from haiku.rag.client import HaikuRAG
|
||||
from haiku.rag.config import AppConfig, get_config
|
||||
from haiku.rag.context import build_toc
|
||||
from haiku.rag.sandbox import AnalysisContext, Sandbox, recovery_hint
|
||||
from haiku.rag.store.models import Document, SearchResult
|
||||
from haiku.rag.tools.document import DocumentInfo
|
||||
from haiku.rag.utils import format_citations
|
||||
from haiku.rag.store.schema import DocumentMetaRecord
|
||||
from haiku.rag.tools.document import DocumentInfo, DocumentSection, OutlineNode
|
||||
from haiku.rag.tools.search import collect_pictures
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
from haiku.rag.client.scope import DatabaseScope
|
||||
from haiku.rag.store.models.document_item import DocumentItem
|
||||
|
||||
_FILTER_COLUMNS = ", ".join(DocumentMetaRecord.model_fields)
|
||||
|
||||
Filter = Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
description=(
|
||||
f"SQL WHERE clause over the document columns {_FILTER_COLUMNS}, "
|
||||
"restricting which documents are used. `metadata` is a JSON string, "
|
||||
'so match its keys with LIKE: metadata LIKE \'%"author": "Smith"%\'. '
|
||||
"Also uri LIKE '%.pdf', title = 'Q3 report'."
|
||||
)
|
||||
),
|
||||
]
|
||||
Sources = Annotated[
|
||||
list[str] | None,
|
||||
Field(description="Collections to use, by name. All of them by default."),
|
||||
]
|
||||
|
||||
|
||||
def _decode_images(images_base64: list[str] | None) -> list[bytes] | None:
|
||||
if not images_base64:
|
||||
return None
|
||||
import base64
|
||||
def _read_only(title: str) -> ToolAnnotations:
|
||||
return ToolAnnotations(title=title, read_only_hint=True, open_world_hint=False)
|
||||
|
||||
return [base64.b64decode(b64, validate=True) for b64 in images_base64]
|
||||
|
||||
def _decode_image(image_base64: str) -> bytes:
|
||||
try:
|
||||
return base64.b64decode(image_base64, validate=True)
|
||||
except ValueError as e:
|
||||
# binascii.Error for characters outside the alphabet or bad padding,
|
||||
# ValueError itself for non-ASCII input.
|
||||
raise ToolError("Invalid base64 image") from e
|
||||
|
||||
|
||||
def _instructions(scope: "DatabaseScope", config: AppConfig) -> str:
|
||||
"""What the server is for, naming no tools: the client has every tool's
|
||||
description from the listing."""
|
||||
lines = [
|
||||
"haiku-rag is the user's knowledge base: documents they ingested, "
|
||||
"searchable by meaning and keyword, readable whole or section by section, "
|
||||
"or computed across with code."
|
||||
]
|
||||
lines.append(
|
||||
"Use it whenever a question could be answered from those documents, "
|
||||
"before answering from memory, and say when it had nothing relevant."
|
||||
)
|
||||
if scope.covers_multiple:
|
||||
lines.append(
|
||||
f"It holds several collections: {', '.join(scope.names)}. Results "
|
||||
"name theirs in `source`; pass `sources` to use a subset."
|
||||
)
|
||||
if config.prompts.domain_preamble:
|
||||
lines.append(config.prompts.domain_preamble)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _search_result(results: list[SearchResult], covers_multiple: bool) -> ToolResult:
|
||||
"""Results as the in-process agents read them, plus the matched chunk's
|
||||
metadata, then each distinct picture as an image block labelled with its
|
||||
result. No structured content: a client given both shows the model the
|
||||
JSON and drops the text, or shows both."""
|
||||
total = len(results)
|
||||
text = "\n\n".join(
|
||||
result.format_for_agent(
|
||||
rank=rank,
|
||||
total=total,
|
||||
include_collection=covers_multiple,
|
||||
include_document_id=True,
|
||||
include_chunk_meta=True,
|
||||
)
|
||||
for rank, result in enumerate(results, 1)
|
||||
)
|
||||
content: list[ContentBlock] = [
|
||||
TextContent(type="text", text=text or "No results found.")
|
||||
]
|
||||
pictures, _ = collect_pictures(results)
|
||||
for source, chunk_id, self_ref, picture in pictures:
|
||||
collection = f" in {source}" if covers_multiple and source else ""
|
||||
content.append(
|
||||
TextContent(
|
||||
type="text",
|
||||
text=f"Picture {self_ref} of search result [{chunk_id}]{collection}",
|
||||
)
|
||||
)
|
||||
content.append(
|
||||
ImageContent(
|
||||
type="image",
|
||||
data=base64.b64encode(picture.data).decode("ascii"),
|
||||
mime_type="image/png",
|
||||
)
|
||||
)
|
||||
return ToolResult(content=content)
|
||||
|
||||
|
||||
def _node(toc: "dict[str, Any]") -> OutlineNode:
|
||||
return OutlineNode(
|
||||
id=toc["self_ref"],
|
||||
title=toc["title"],
|
||||
level=toc["level"],
|
||||
page_numbers=toc["page_numbers"],
|
||||
children=[_node(child) for child in toc["children"]],
|
||||
)
|
||||
|
||||
|
||||
def _find(toc: list["dict[str, Any]"], section_id: str) -> "dict[str, Any] | None":
|
||||
for node in toc:
|
||||
if node["self_ref"] == section_id:
|
||||
return node
|
||||
found = _find(node["children"], section_id)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def create_mcp_server(
|
||||
db_path: Path | None = None,
|
||||
config: AppConfig | None = None,
|
||||
read_only: bool = False,
|
||||
db_path: Path | None = None, config: AppConfig | None = None
|
||||
) -> FastMCP:
|
||||
"""Create an MCP server over one database.
|
||||
"""Create an MCP server over the databases the configuration places.
|
||||
|
||||
Args:
|
||||
db_path: Path to the database file, where `config` places none; or
|
||||
None to serve the database the configuration places. Beside
|
||||
None to serve the databases the configuration places. Beside
|
||||
`lancedb.databases` a path raises `AmbiguousDatabaseError`.
|
||||
config: Configuration to use.
|
||||
read_only: If True, write tools (add_document_*, delete_document) are not registered.
|
||||
"""
|
||||
from haiku.rag.client.scope import DatabaseScope
|
||||
|
||||
config = config if config is not None else get_config()
|
||||
return _covering(
|
||||
DatabaseScope.resolve(config, database_path=db_path), config, read_only
|
||||
)
|
||||
return _covering(DatabaseScope.resolve(config, database_path=db_path), config)
|
||||
|
||||
|
||||
def _covering(scope: "DatabaseScope", config: AppConfig, read_only: bool) -> FastMCP:
|
||||
def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
|
||||
"""An MCP server over databases someone already resolved.
|
||||
|
||||
Internal, as ``HaikuRAG._covering`` is: the public factory takes a path and
|
||||
resolves it, which is its own job. A caller that resolved already passes the
|
||||
scope, so the configured name survives, which results and citations carry as
|
||||
``source``.
|
||||
scope, so the configured name survives, which results carry as ``source``.
|
||||
"""
|
||||
from haiku.rag.store.exceptions import AmbiguousDatabaseError
|
||||
|
||||
if scope.covers_multiple:
|
||||
raise AmbiguousDatabaseError(
|
||||
"an MCP server serves one database, and this scope covers "
|
||||
f"{', '.join(scope.names)}; name the one to serve"
|
||||
)
|
||||
client: HaikuRAG | None = None
|
||||
stack = AsyncExitStack()
|
||||
client_lock = asyncio.Lock()
|
||||
|
|
@ -76,7 +178,7 @@ def _covering(scope: "DatabaseScope", config: AppConfig, read_only: bool) -> Fas
|
|||
async with client_lock:
|
||||
if client is None:
|
||||
client = await stack.enter_async_context(
|
||||
HaikuRAG._covering(scope, config, read_only=read_only)
|
||||
HaikuRAG._covering(scope, config, read_only=True)
|
||||
)
|
||||
return client
|
||||
|
||||
|
|
@ -95,90 +197,53 @@ def _covering(scope: "DatabaseScope", config: AppConfig, read_only: bool) -> Fas
|
|||
finally:
|
||||
client = None
|
||||
|
||||
mcp = FastMCP("haiku-rag", lifespan=lifespan)
|
||||
# Explicit: the setting is also read from the environment, and the contract
|
||||
# is that every failure reaches the client with its message.
|
||||
mcp = FastMCP(
|
||||
"haiku-rag",
|
||||
instructions=_instructions(scope, config),
|
||||
version=metadata.version("haiku.rag-slim"),
|
||||
lifespan=lifespan,
|
||||
mask_error_details=False,
|
||||
)
|
||||
|
||||
# Write tools - only registered when not in read-only mode
|
||||
if not read_only:
|
||||
|
||||
@mcp.tool()
|
||||
async def add_document_from_file(
|
||||
file_path: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
title: str | None = None,
|
||||
) -> str | None:
|
||||
"""Add a document to the RAG system from a file path."""
|
||||
try:
|
||||
rag = await _client()
|
||||
result = await rag.create_document_from_source(
|
||||
Path(file_path), title=title, metadata=metadata or {}
|
||||
)
|
||||
# Handle both single document and list of documents (directories)
|
||||
if isinstance(result, list):
|
||||
return result[0].id if result else None
|
||||
return result.id
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@mcp.tool()
|
||||
async def add_document_from_url(
|
||||
url: str, metadata: dict[str, Any] | None = None, title: str | None = None
|
||||
) -> str | None:
|
||||
"""Add a document to the RAG system from a URL."""
|
||||
try:
|
||||
rag = await _client()
|
||||
result = await rag.create_document_from_source(
|
||||
url, title=title, metadata=metadata or {}
|
||||
)
|
||||
# Handle both single document and list of documents
|
||||
if isinstance(result, list):
|
||||
return result[0].id if result else None
|
||||
return result.id
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@mcp.tool()
|
||||
async def add_document_from_text(
|
||||
content: str,
|
||||
uri: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
title: str | None = None,
|
||||
) -> str | None:
|
||||
"""Add a document to the RAG system from text content."""
|
||||
try:
|
||||
rag = await _client()
|
||||
document = await rag.create_document(
|
||||
content, uri, title=title, metadata=metadata or {}
|
||||
)
|
||||
return document.id
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@mcp.tool()
|
||||
async def delete_document(document_id: str) -> bool:
|
||||
"""Delete a document by its ID."""
|
||||
try:
|
||||
rag = await _client()
|
||||
return await rag.delete_document(document_id)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# Read tools - always registered
|
||||
@mcp.tool()
|
||||
@mcp.tool(annotations=_read_only("Search documents"))
|
||||
async def search_documents(
|
||||
query: str, limit: int | None = None, include_images: bool = True
|
||||
) -> list[SearchResult]:
|
||||
"""Search the RAG system for documents using hybrid search (vector similarity + full-text search).
|
||||
query: str,
|
||||
limit: int | None = None,
|
||||
include_images: bool = True,
|
||||
filter: Filter = None,
|
||||
sources: Sources = None,
|
||||
) -> ToolResult:
|
||||
"""Search the knowledge base by meaning and keyword.
|
||||
|
||||
When include_images is True (default) and a picture-labeled chunk is
|
||||
in the result set, ``SearchResult.image_data`` carries base64-encoded
|
||||
PNG bytes keyed by self_ref. Set to False to omit the bytes from the
|
||||
response (smaller JSON payload for plain-text consumers).
|
||||
Use this first for any question the documents might answer; it needs
|
||||
no model and is the cheapest call. Results come best first, each with
|
||||
its rank, `Document ID`, `Collection` when the server covers several,
|
||||
the document title, section headings, the matched chunk's metadata
|
||||
when it has any, and the matching passage expanded to its section;
|
||||
pass the id and collection to the document tools. Pictures in the
|
||||
results follow as images, each labelled with its result. Ranks, not scores,
|
||||
are the signal: scores are not comparable across queries. If nothing
|
||||
relevant comes back, rephrase once or narrow with `filter` before
|
||||
concluding the material is absent.
|
||||
|
||||
Args:
|
||||
query: What to look for, in natural language or keywords.
|
||||
limit: How many results to return; the server's configured default
|
||||
when omitted.
|
||||
include_images: Return the pictures in the results as images.
|
||||
False for a smaller response.
|
||||
"""
|
||||
try:
|
||||
rag = await _client()
|
||||
return await rag.search(query, limit=limit, include_images=include_images)
|
||||
except Exception:
|
||||
return []
|
||||
rag = await _client()
|
||||
results = await rag.search(
|
||||
query,
|
||||
limit=limit,
|
||||
filter=filter,
|
||||
include_images=include_images,
|
||||
sources=sources,
|
||||
)
|
||||
return _search_result(await rag.expand_context(results), rag.covers_multiple)
|
||||
|
||||
# Image-as-query tool, only registered when the configured embedder
|
||||
# supports image embeddings. Probed at server-build time when no Store is
|
||||
|
|
@ -188,123 +253,208 @@ def _covering(scope: "DatabaseScope", config: AppConfig, read_only: bool) -> Fas
|
|||
|
||||
if get_embedder(config).supports_images:
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(annotations=_read_only("Search documents by image"))
|
||||
async def search_documents_by_image(
|
||||
image_base64: str,
|
||||
limit: int | None = None,
|
||||
include_images: bool = True,
|
||||
) -> list[SearchResult]:
|
||||
"""Search the RAG system using an image as the query.
|
||||
filter: Filter = None,
|
||||
sources: Sources = None,
|
||||
) -> ToolResult:
|
||||
"""Search the knowledge base with an image as the query.
|
||||
|
||||
``image_base64`` is a base64-encoded image (PNG/JPEG bytes). The
|
||||
image is embedded via the configured multimodal embedder and the
|
||||
chunks table is searched vector-only. ``include_images`` controls
|
||||
whether picture bytes are attached to picture-labeled results.
|
||||
Use this when the question is about a picture rather than words.
|
||||
The image is embedded and matched against document text and
|
||||
figures by vector similarity alone. Results have the shape of
|
||||
`search_documents` results.
|
||||
|
||||
Args:
|
||||
image_base64: The query image, PNG or JPEG bytes as base64.
|
||||
limit: How many results to return; the server's configured
|
||||
default when omitted.
|
||||
include_images: Return the pictures in the results as images.
|
||||
False for a smaller response.
|
||||
"""
|
||||
import base64
|
||||
|
||||
try:
|
||||
raw = base64.b64decode(image_base64)
|
||||
except Exception:
|
||||
return []
|
||||
try:
|
||||
rag = await _client()
|
||||
return await rag.search(raw, limit=limit, include_images=include_images)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@mcp.tool()
|
||||
async def get_document(document_id: str) -> Document | None:
|
||||
"""Get a document by its ID."""
|
||||
try:
|
||||
raw = _decode_image(image_base64)
|
||||
rag = await _client()
|
||||
return await rag.get_document_by_id(document_id)
|
||||
except Exception:
|
||||
return None
|
||||
results = await rag.search(
|
||||
raw,
|
||||
limit=limit,
|
||||
filter=filter,
|
||||
include_images=include_images,
|
||||
sources=sources,
|
||||
)
|
||||
return _search_result(
|
||||
await rag.expand_context(results), rag.covers_multiple
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(annotations=_read_only("Get document"))
|
||||
async def get_document(document_id: str, source: str | None = None) -> Document:
|
||||
"""Read one document whole, in reading order.
|
||||
|
||||
Use this after a search when a passage is not enough. Returns the
|
||||
document's content, title, uri and metadata. Ids come from search
|
||||
results and `list_documents`.
|
||||
|
||||
Args:
|
||||
document_id: The document's id.
|
||||
source: The collection holding it. Without one every collection
|
||||
is asked.
|
||||
"""
|
||||
rag = await _client()
|
||||
document = await rag.get_document_by_id(document_id, source)
|
||||
if document is None:
|
||||
raise ToolError(f"No document with id {document_id!r}")
|
||||
return document
|
||||
|
||||
async def _items_of(document_id: str, source: str | None) -> list["DocumentItem"]:
|
||||
"""A document's items in reading order, from the database holding it."""
|
||||
rag = await _client()
|
||||
document = await rag.get_document_by_id(document_id, source)
|
||||
if document is None:
|
||||
raise ToolError(f"No document with id {document_id!r}")
|
||||
owner = await rag.reader_for(source or document.source)
|
||||
assert owner is not None, "a stored document names its database"
|
||||
return await owner.document_item_repository.get_all_items(document_id)
|
||||
|
||||
@mcp.tool(annotations=_read_only("Document outline"))
|
||||
async def get_document_outline(
|
||||
document_id: str, source: str | None = None
|
||||
) -> list[OutlineNode]:
|
||||
"""The heading tree of a document, with page numbers.
|
||||
|
||||
Use this on a long document to see its structure before reading, then
|
||||
pass a node's `id` to `get_document_section`. Returns the headings
|
||||
nested by level; an empty list means the document has no headings,
|
||||
so read it with `get_document`.
|
||||
|
||||
Args:
|
||||
document_id: The document's id.
|
||||
source: The collection holding it. Without one every collection
|
||||
is asked.
|
||||
"""
|
||||
return [
|
||||
_node(toc) for toc in build_toc(await _items_of(document_id, source), {})
|
||||
]
|
||||
|
||||
@mcp.tool(annotations=_read_only("Document section"))
|
||||
async def get_document_section(
|
||||
document_id: str, section_id: str, source: str | None = None
|
||||
) -> DocumentSection:
|
||||
"""The text of one section of a document, subsections included.
|
||||
|
||||
Use this to read a part of a long document instead of the whole.
|
||||
`section_id` is a node `id` from `get_document_outline`. Returns the
|
||||
section's heading, page numbers and text in reading order, up to the
|
||||
next heading of the same or a higher level.
|
||||
|
||||
Args:
|
||||
document_id: The document's id.
|
||||
section_id: The `id` of a node in the document's outline.
|
||||
source: The collection holding it. Without one every collection
|
||||
is asked.
|
||||
"""
|
||||
items = await _items_of(document_id, source)
|
||||
node = _find(build_toc(items, {}), section_id)
|
||||
if node is None:
|
||||
raise ToolError(f"No section {section_id!r} in document {document_id!r}")
|
||||
start, end = node["item_range"]
|
||||
ordered = sorted(items, key=lambda item: item.position)
|
||||
return DocumentSection(
|
||||
id=node["self_ref"],
|
||||
title=node["title"],
|
||||
page_numbers=node["page_numbers"],
|
||||
content="\n\n".join(item.text for item in ordered[start:end] if item.text),
|
||||
)
|
||||
|
||||
@mcp.tool(annotations=_read_only("List documents"))
|
||||
async def list_documents(
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
filter: str | None = None,
|
||||
filter: Filter = None,
|
||||
) -> list[DocumentInfo]:
|
||||
"""List all documents with optional pagination and filtering.
|
||||
"""List what the knowledge base holds.
|
||||
|
||||
Use this to see which documents exist, their titles, URIs and
|
||||
metadata, and so what a `filter` can match. Not a search: it returns
|
||||
no passages.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of documents to return.
|
||||
offset: Number of documents to skip.
|
||||
filter: Optional SQL WHERE clause to filter documents.
|
||||
limit: How many documents to return.
|
||||
offset: How many documents to skip, for paging.
|
||||
"""
|
||||
try:
|
||||
rag = await _client()
|
||||
documents = await rag.list_documents(limit, offset, filter)
|
||||
rag = await _client()
|
||||
documents = await rag.list_documents(limit, offset, filter)
|
||||
return [
|
||||
DocumentInfo(
|
||||
id=doc.id,
|
||||
title=doc.title or "Untitled",
|
||||
uri=doc.uri or "",
|
||||
created=doc.created_at.strftime("%Y-%m-%d"),
|
||||
source=doc.source,
|
||||
metadata=doc.metadata,
|
||||
)
|
||||
for doc in documents
|
||||
]
|
||||
|
||||
return [
|
||||
DocumentInfo(
|
||||
id=doc.id,
|
||||
title=doc.title or "Untitled",
|
||||
uri=doc.uri or "",
|
||||
created=doc.created_at.strftime("%Y-%m-%d"),
|
||||
)
|
||||
for doc in documents
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@mcp.tool()
|
||||
async def ask_question(
|
||||
question: str,
|
||||
cite: bool = False,
|
||||
images_base64: list[str] | None = None,
|
||||
@mcp.tool(annotations=_read_only("Run code over the documents"))
|
||||
async def execute_code(
|
||||
code: str, filter: Filter = None, sources: Sources = None
|
||||
) -> str:
|
||||
"""Ask a question using the QA agent.
|
||||
"""Run a Python program over the documents and return what it printed.
|
||||
|
||||
Use this when the answer is a count, an aggregate, a comparison across
|
||||
many documents, a lookup by document or chunk metadata, or a pattern
|
||||
over whole documents: whatever a search cannot rank. The program runs
|
||||
in a sandboxed interpreter on the server. Each call is one program,
|
||||
nothing carries over between calls, and `print` is the only output.
|
||||
|
||||
Inside the program, `/documents/{document_id}/` holds `metadata.json`
|
||||
(id, title, uri, created_at, metadata), `content.txt` (the whole text),
|
||||
`items.jsonl` (one item per line: self_ref, label, text, page_numbers,
|
||||
heading_level, chunk_ids), `chunks.jsonl` (one chunk per line: chunk_id,
|
||||
metadata) and `toc.json` (`doc_id`, `title`, `tree`; each node has
|
||||
self_ref, level, title, page_numbers, item_range as a slice into
|
||||
items.jsonl, chunk_ids and children; an empty tree means no headings).
|
||||
Read files with `Path.read_text()` or `open()`; a file object cannot be
|
||||
iterated, use `.readlines()`. `await search(query, limit=10)` returns
|
||||
dicts with chunk_id, content, document_id, document_title, document_uri,
|
||||
source, score, page_numbers, headings, doc_item_refs, labels,
|
||||
picture_refs (the doc_item_refs that are pictures) and chunk_meta.
|
||||
`await list_documents()` returns dicts with id, title, uri, created_at,
|
||||
source and metadata. Both see the documents `filter` and `sources`
|
||||
select. Useful modules include json, re, math, pathlib, datetime,
|
||||
collections, itertools, functools and dataclasses; decimal and
|
||||
statistics do not exist. No generator functions, match statements or
|
||||
class inheritance.
|
||||
Files are read-only, there is no network, a call has a time limit named
|
||||
in the error when it is hit, and output past a size is truncated.
|
||||
|
||||
Map a title or URI to a document id with one `list_documents()` call
|
||||
rather than reading every `metadata.json`. The files carry no `source`,
|
||||
so over several collections group by the `source` of `list_documents()`
|
||||
rows. For a known document's structure read its `toc.json` before
|
||||
searching: `search()` ranks across every document. A hit's
|
||||
`doc_item_refs` are `self_ref` values in `items.jsonl`, which places it
|
||||
in its section. `chunk_ids` on items and `chunk_id` in `chunks.jsonl`
|
||||
join the two files; they are not citations.
|
||||
|
||||
Args:
|
||||
question: The question to ask.
|
||||
cite: Whether to include citations in the response.
|
||||
images_base64: Base64-encoded images attached to the question
|
||||
(requires a vision-capable QA model).
|
||||
|
||||
Returns:
|
||||
The answer as a string.
|
||||
code: The program. Use `await` on search and list_documents.
|
||||
"""
|
||||
rag = await _client()
|
||||
sandbox = Sandbox._covering(
|
||||
scope, config, AnalysisContext(filter=filter, sources=sources), rag=rag
|
||||
)
|
||||
try:
|
||||
images = _decode_images(images_base64)
|
||||
rag = await _client()
|
||||
answer, citations = await rag.ask(question, images=images)
|
||||
if cite and citations:
|
||||
answer += "\n\n" + format_citations(citations)
|
||||
return answer
|
||||
except Exception as e:
|
||||
return f"Error answering question: {e!s}"
|
||||
|
||||
@mcp.tool()
|
||||
async def analyze(
|
||||
question: str,
|
||||
filter: str | None = None,
|
||||
images_base64: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Answer complex questions using the analysis capability.
|
||||
|
||||
Use this for questions requiring computation, aggregation, or
|
||||
structural traversal across documents. The capability can write and
|
||||
execute Python code in a sandboxed interpreter.
|
||||
|
||||
Args:
|
||||
question: The question to answer.
|
||||
filter: Optional SQL WHERE clause to filter documents.
|
||||
images_base64: Base64-encoded images attached to the question
|
||||
(requires a vision-capable analysis model).
|
||||
|
||||
Returns:
|
||||
The answer as a string.
|
||||
"""
|
||||
try:
|
||||
images = _decode_images(images_base64)
|
||||
rag = await _client()
|
||||
result = await rag.analyze(question, filter=filter, images=images)
|
||||
return result.answer
|
||||
except Exception as e:
|
||||
return f"Error running analysis capability: {e!s}"
|
||||
result = await sandbox.execute(code)
|
||||
finally:
|
||||
await sandbox.close()
|
||||
if not result.success:
|
||||
raise ToolError(
|
||||
f"{result.stderr}{recovery_hint(result.stderr)}"
|
||||
f"\n\nOutput: {result.stdout}"
|
||||
)
|
||||
return result.stdout or "No output."
|
||||
|
||||
return mcp
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
from haiku.rag.sandbox.dependencies import AnalysisContext
|
||||
from haiku.rag.sandbox.models import AnalysisResult
|
||||
from haiku.rag.sandbox.sandbox import Sandbox, SandboxResult
|
||||
from haiku.rag.sandbox.sandbox import Sandbox, SandboxResult, recovery_hint
|
||||
|
||||
__all__ = [
|
||||
"AnalysisContext",
|
||||
"AnalysisResult",
|
||||
"Sandbox",
|
||||
"SandboxResult",
|
||||
"recovery_hint",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ from pydantic_monty import (
|
|||
)
|
||||
|
||||
from haiku.rag.config.models import AppConfig
|
||||
from haiku.rag.context import build_toc
|
||||
from haiku.rag.sandbox.dependencies import AnalysisContext
|
||||
from haiku.rag.store.models.chunk import SearchResult
|
||||
from haiku.rag.store.models.chunk import Chunk, SearchResult
|
||||
from haiku.rag.store.models.document_item import PICTURE_REF_PREFIX, DocumentItem
|
||||
from haiku.rag.utils import gather_all
|
||||
|
||||
|
|
@ -29,6 +30,9 @@ if TYPE_CHECKING:
|
|||
from haiku.rag.client.scope import DatabaseScope
|
||||
|
||||
|
||||
_MAX_HOST_CALLS = 10_000_000
|
||||
|
||||
|
||||
@dataclass
|
||||
class SandboxResult:
|
||||
"""Result of executing code in the sandbox."""
|
||||
|
|
@ -38,79 +42,19 @@ class SandboxResult:
|
|||
success: bool
|
||||
|
||||
|
||||
def _build_toc(
|
||||
items: list["DocumentItem"],
|
||||
chunk_index: dict[str, list[str]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build a nested section tree from items in position order.
|
||||
def recovery_hint(stderr: str) -> str:
|
||||
"""Name the workaround for sandbox limits models trip over repeatedly.
|
||||
|
||||
Each ``section_header`` with ``heading_level > 0`` becomes a node. Nesting
|
||||
follows the explicit levels: a header pops the stack until the top is at
|
||||
a strictly shallower level, then becomes a child of that top (or a root).
|
||||
|
||||
``item_range = [position, end_exclusive]`` where ``end_exclusive`` is the
|
||||
position of the next header whose level is the same or shallower (i.e.
|
||||
the next sibling or ancestor that ends this section), or the total item
|
||||
count if no such header exists.
|
||||
|
||||
``chunk_ids`` aggregates the chunks covered by all items in the section's
|
||||
``item_range`` (deduped, order preserved). Pass directly to ``cite()`` to
|
||||
ground a section-scoped answer without a corpus-wide ``search()`` call.
|
||||
|
||||
Items without a section_header label (or with ``heading_level == 0``) are
|
||||
skipped. When all section_headers carry the same level the output is a
|
||||
flat sibling list (see docling-project/docling#2121 for an upstream case
|
||||
where every PDF section_header is emitted at level=1).
|
||||
The instructions already say file objects are not iterable, and models write
|
||||
``for line in open(...)`` regardless. Carrying the fix in the error gives
|
||||
them something to act on for the retry.
|
||||
"""
|
||||
# Defensive: every consumer is supposed to pass items in position order,
|
||||
# but the end_exclusive lookahead below silently miscomputes section
|
||||
# boundaries if it's not — better to sort once than trust the caller.
|
||||
items = sorted(items, key=lambda i: i.position)
|
||||
headers: list[DocumentItem] = [
|
||||
i for i in items if i.label == "section_header" and i.heading_level > 0
|
||||
]
|
||||
if not headers:
|
||||
return []
|
||||
|
||||
total = max((i.position for i in items), default=-1) + 1
|
||||
items_by_position: dict[int, DocumentItem] = {i.position: i for i in items}
|
||||
|
||||
ends: list[int] = []
|
||||
for idx, h in enumerate(headers):
|
||||
end = total
|
||||
for j in range(idx + 1, len(headers)):
|
||||
if headers[j].heading_level <= h.heading_level:
|
||||
end = headers[j].position
|
||||
break
|
||||
ends.append(end)
|
||||
|
||||
roots: list[dict[str, Any]] = []
|
||||
stack: list[tuple[int, dict[str, Any]]] = []
|
||||
for h, end in zip(headers, ends, strict=True):
|
||||
seen: set[str] = set()
|
||||
chunk_ids: list[str] = []
|
||||
for pos in range(h.position, end):
|
||||
item = items_by_position.get(pos)
|
||||
if item is None:
|
||||
continue
|
||||
for cid in chunk_index.get(item.self_ref, []):
|
||||
if cid not in seen:
|
||||
seen.add(cid)
|
||||
chunk_ids.append(cid)
|
||||
node: dict[str, Any] = {
|
||||
"self_ref": h.self_ref,
|
||||
"level": h.heading_level,
|
||||
"title": h.text,
|
||||
"page_numbers": list(h.page_numbers),
|
||||
"item_range": [h.position, end],
|
||||
"chunk_ids": chunk_ids,
|
||||
"children": [],
|
||||
}
|
||||
while stack and stack[-1][0] >= h.heading_level:
|
||||
stack.pop()
|
||||
(stack[-1][1]["children"] if stack else roots).append(node)
|
||||
stack.append((h.heading_level, node))
|
||||
return roots
|
||||
if "TextIOWrapper" in stderr and "not iterable" in stderr:
|
||||
return (
|
||||
"\n\nHint: file objects cannot be iterated here. Read lines with "
|
||||
'.readlines() or .read().split("\\n").'
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
class Sandbox:
|
||||
|
|
@ -120,7 +64,8 @@ class Sandbox:
|
|||
The interpreter runs in a subprocess worker checked out of an ``AsyncMonty``
|
||||
pool. External functions (search, list_documents) are called by Monty code
|
||||
using ``await`` and resolved asynchronously on the host. Documents are
|
||||
exposed via a virtual filesystem at ``/documents/{id}/``.
|
||||
exposed via a virtual filesystem at ``/documents/{id}/``: ``metadata.json``,
|
||||
``content.txt``, ``items.jsonl``, ``chunks.jsonl`` and ``toc.json``.
|
||||
|
||||
The session persists across ``execute()`` calls within the same Sandbox
|
||||
instance — variables carry over. Call ``close()`` to return the worker to
|
||||
|
|
@ -150,6 +95,7 @@ class Sandbox:
|
|||
_doc_items: dict[str, list["DocumentItem"]]
|
||||
_doc_chunk_index: dict[str, dict[str, list[str]]]
|
||||
_items_jsonl_cache: dict[str, str]
|
||||
_chunks_jsonl_cache: dict[str, str]
|
||||
_toc_json_cache: dict[str, str]
|
||||
_opened: "HaikuRAG | None"
|
||||
_pool: AsyncMonty | None
|
||||
|
|
@ -216,6 +162,7 @@ class Sandbox:
|
|||
self._doc_items = {}
|
||||
self._doc_chunk_index = {}
|
||||
self._items_jsonl_cache = {}
|
||||
self._chunks_jsonl_cache = {}
|
||||
self._toc_json_cache = {}
|
||||
self._pool = None
|
||||
self._session = None
|
||||
|
|
@ -328,14 +275,43 @@ class Sandbox:
|
|||
assert self._loop is not None, (
|
||||
"VFS reads happen during execute(); the loop must be captured first."
|
||||
)
|
||||
if self._deadline is not None and self._loop.time() > self._deadline:
|
||||
if self._past_deadline():
|
||||
coro.close()
|
||||
raise TimeoutError(
|
||||
"time limit exceeded: no further document reads after "
|
||||
f"{self._config.analysis.code_timeout}s"
|
||||
)
|
||||
raise self._time_limit()
|
||||
return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
|
||||
|
||||
def _past_deadline(self) -> bool:
|
||||
return (
|
||||
self._deadline is not None
|
||||
and self._loop is not None
|
||||
and self._loop.time() > self._deadline
|
||||
)
|
||||
|
||||
def _time_limit(self) -> TimeoutError:
|
||||
return TimeoutError(
|
||||
"time limit exceeded: no further document reads or calls after "
|
||||
f"{self._config.analysis.code_timeout}s"
|
||||
)
|
||||
|
||||
def _check_deadline(self) -> None:
|
||||
"""Refuse a host call once the call's time is up.
|
||||
|
||||
Monty's watchdog counts only time the worker spends computing, so every
|
||||
host call, a file served from memory and an in-code search included,
|
||||
checks the deadline before it runs.
|
||||
"""
|
||||
if self._past_deadline():
|
||||
raise self._time_limit()
|
||||
|
||||
def _timed(
|
||||
self, read: Callable[["PurePosixPath"], str]
|
||||
) -> Callable[["PurePosixPath"], str]:
|
||||
def call(path: "PurePosixPath") -> str:
|
||||
self._check_deadline()
|
||||
return read(path)
|
||||
|
||||
return call
|
||||
|
||||
async def _discard_session(self) -> None:
|
||||
"""Drop a session whose worker is gone.
|
||||
|
||||
|
|
@ -371,6 +347,7 @@ class Sandbox:
|
|||
context = self._context
|
||||
|
||||
async def search(query: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||
self._check_deadline()
|
||||
# Picture bytes are deliberately not attached to in-code search
|
||||
# results: the Monty interpreter has no PIL/base64/hashlib, so the
|
||||
# agent's Python can't do anything with them. The driving model
|
||||
|
|
@ -404,11 +381,13 @@ class Sandbox:
|
|||
"doc_item_refs": r.doc_item_refs,
|
||||
"labels": r.labels,
|
||||
"picture_refs": picture_refs,
|
||||
"chunk_meta": r.chunk_meta,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
async def list_documents() -> list[dict[str, Any]]:
|
||||
self._check_deadline()
|
||||
docs, _ = await self._documents()
|
||||
return [
|
||||
{
|
||||
|
|
@ -417,6 +396,7 @@ class Sandbox:
|
|||
"uri": d.uri,
|
||||
"created_at": str(d.created_at),
|
||||
"source": d.source,
|
||||
"metadata": d.metadata,
|
||||
}
|
||||
for d in docs
|
||||
]
|
||||
|
|
@ -433,6 +413,7 @@ class Sandbox:
|
|||
- metadata.json: CallbackFile (eager, small)
|
||||
- content.txt: CallbackFile (lazy, can be large)
|
||||
- items.jsonl: CallbackFile (lazy, bulk-cached)
|
||||
- chunks.jsonl: CallbackFile (lazy, bulk-cached)
|
||||
- toc.json: CallbackFile (lazy, bulk-cached)
|
||||
"""
|
||||
files: list[CallbackFile] = []
|
||||
|
|
@ -507,6 +488,31 @@ class Sandbox:
|
|||
|
||||
return read_items
|
||||
|
||||
def _make_chunks_reader(
|
||||
did: str,
|
||||
) -> Callable[["PurePosixPath"], str]:
|
||||
def read_chunks(_path: "PurePosixPath") -> str:
|
||||
cached = sandbox._chunks_jsonl_cache.get(did)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
async def _fetch() -> list[Chunk]:
|
||||
async with sandbox._connection(sandbox._owners.get(did)) as rag:
|
||||
return await rag.chunk_repository.get_by_document_id(did)
|
||||
|
||||
chunks = sandbox._run_on_loop(_fetch())
|
||||
jsonl = "\n".join(
|
||||
json.dumps(
|
||||
{"chunk_id": chunk.id, "metadata": chunk.metadata},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
for chunk in chunks
|
||||
)
|
||||
sandbox._chunks_jsonl_cache[did] = jsonl
|
||||
return jsonl
|
||||
|
||||
return read_chunks
|
||||
|
||||
def _make_toc_reader(
|
||||
did: str,
|
||||
) -> Callable[["PurePosixPath"], str]:
|
||||
|
|
@ -520,7 +526,7 @@ class Sandbox:
|
|||
{
|
||||
"doc_id": did,
|
||||
"title": doc_titles.get(did),
|
||||
"tree": _build_toc(items, chunk_index),
|
||||
"tree": build_toc(items, chunk_index),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
|
@ -541,6 +547,7 @@ class Sandbox:
|
|||
"title": doc.title,
|
||||
"uri": doc.uri,
|
||||
"created_at": str(doc.created_at),
|
||||
"metadata": doc.metadata,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
|
@ -550,7 +557,7 @@ class Sandbox:
|
|||
files.append(
|
||||
CallbackFile(
|
||||
f"{doc_dir}/metadata.json",
|
||||
read=lambda _path, text=metadata: text,
|
||||
read=self._timed(lambda _path, text=metadata: text),
|
||||
write=_deny_write,
|
||||
)
|
||||
)
|
||||
|
|
@ -571,14 +578,21 @@ class Sandbox:
|
|||
files.append(
|
||||
CallbackFile(
|
||||
f"{doc_dir}/content.txt",
|
||||
read=_make_content_reader(doc_id),
|
||||
read=self._timed(_make_content_reader(doc_id)),
|
||||
write=_deny_write,
|
||||
)
|
||||
)
|
||||
files.append(
|
||||
CallbackFile(
|
||||
f"{doc_dir}/items.jsonl",
|
||||
read=_make_items_reader(doc_id),
|
||||
read=self._timed(_make_items_reader(doc_id)),
|
||||
write=_deny_write,
|
||||
)
|
||||
)
|
||||
files.append(
|
||||
CallbackFile(
|
||||
f"{doc_dir}/chunks.jsonl",
|
||||
read=self._timed(_make_chunks_reader(doc_id)),
|
||||
write=_deny_write,
|
||||
)
|
||||
)
|
||||
|
|
@ -589,7 +603,7 @@ class Sandbox:
|
|||
files.append(
|
||||
CallbackFile(
|
||||
f"{doc_dir}/toc.json",
|
||||
read=_make_toc_reader(doc_id),
|
||||
read=self._timed(_make_toc_reader(doc_id)),
|
||||
write=_deny_write,
|
||||
)
|
||||
)
|
||||
|
|
@ -601,12 +615,19 @@ class Sandbox:
|
|||
|
||||
Monty spends ``max_duration_secs`` across the session's whole life, and
|
||||
the session is reused so variables persist between calls: the budget
|
||||
covers the whole run. ``code_timeout`` is enforced per call elsewhere: the read
|
||||
deadline in ``_run_on_loop`` bounds a call that reads, and the pool's
|
||||
``request_timeout`` bounds one that computes.
|
||||
covers the whole run. ``code_timeout`` is enforced per call elsewhere: past
|
||||
its deadline no further host call starts (``_check_deadline``), and the
|
||||
pool's ``request_timeout`` bounds compute.
|
||||
|
||||
``max_suspensions`` counts host callbacks per session, document reads
|
||||
included, defaults to 1000 and cannot be disabled. The time budgets are
|
||||
the governors here, so it is set where no program reaches it.
|
||||
"""
|
||||
analysis = self._config.analysis
|
||||
return {"max_duration_secs": analysis.code_timeout * analysis.max_executions}
|
||||
return {
|
||||
"max_duration_secs": analysis.code_timeout * analysis.max_executions,
|
||||
"max_suspensions": _MAX_HOST_CALLS,
|
||||
}
|
||||
|
||||
async def _ensure_initialized(self) -> tuple[AsyncMontySession, OSAccess]:
|
||||
"""Check out a worker session and build the VFS on first use."""
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from pydantic import BaseModel, PrivateAttr
|
||||
|
|
@ -143,8 +144,9 @@ class SearchResult(BaseModel):
|
|||
consumers (UIs). Never part of ``format_for_agent`` output.
|
||||
|
||||
``chunk_meta`` is the anchor chunk's unparsed ``Chunk.metadata`` and does not
|
||||
include the metadata of any other chunks merged with it. Never part of
|
||||
``format_for_agent`` output.
|
||||
include the metadata of any other chunks merged with it. Left out of
|
||||
``format_for_agent`` output unless ``include_chunk_meta`` asks for its custom
|
||||
keys.
|
||||
|
||||
``source`` names the database a result came from: the name from
|
||||
``lancedb.databases`` or a path's stem, never a path or URI, so a location
|
||||
|
|
@ -202,6 +204,8 @@ class SearchResult(BaseModel):
|
|||
total: int | None = None,
|
||||
*,
|
||||
include_collection: bool = False,
|
||||
include_document_id: bool = False,
|
||||
include_chunk_meta: bool = False,
|
||||
) -> str:
|
||||
"""Format this search result for inclusion in agent context.
|
||||
|
||||
|
|
@ -215,7 +219,11 @@ class SearchResult(BaseModel):
|
|||
|
||||
`include_collection` is the caller's decision, not this result's: a
|
||||
search spanning one collection has nothing to distinguish, whether or
|
||||
not that collection is named.
|
||||
not that collection is named. `include_document_id` is for a reader
|
||||
that will fetch the document by id from the text alone.
|
||||
`include_chunk_meta` renders the metadata stored with the matched
|
||||
chunk beyond haiku.rag's own structural keys; on an expanded result it
|
||||
locates the hit, not the whole passage.
|
||||
"""
|
||||
if rank is not None and total is not None:
|
||||
parts = [f"[{self.chunk_id}] [rank {rank} of {total}]"]
|
||||
|
|
@ -224,6 +232,9 @@ class SearchResult(BaseModel):
|
|||
else:
|
||||
parts = [f"[{self.chunk_id}] (score: {self.score:.2f})"]
|
||||
|
||||
if include_document_id and self.document_id:
|
||||
parts.append(f"Document ID: {self.document_id}")
|
||||
|
||||
if include_collection and self.source:
|
||||
parts.append(f"Collection: {self.source}")
|
||||
|
||||
|
|
@ -242,6 +253,16 @@ class SearchResult(BaseModel):
|
|||
if primary_label:
|
||||
parts.append(f"Type: {primary_label}")
|
||||
|
||||
if include_chunk_meta:
|
||||
custom = {
|
||||
key: value
|
||||
for key, value in self.chunk_meta.items()
|
||||
if key not in ChunkMetadata.model_fields
|
||||
}
|
||||
if custom:
|
||||
rendered = json.dumps(custom, ensure_ascii=False, sort_keys=True)
|
||||
parts.append(f"Matched chunk metadata: {rendered}")
|
||||
|
||||
# Surface picture captions when present. Order matches the binary
|
||||
# attachments emitted by build_image_content_from_results, so the model
|
||||
# can correlate caption ↔ attached image by position (BinaryContent
|
||||
|
|
|
|||
|
|
@ -27,6 +27,27 @@ class DocumentInfo(BaseModel):
|
|||
title: str
|
||||
uri: str
|
||||
created: str
|
||||
source: str | None = None
|
||||
metadata: dict = {}
|
||||
|
||||
|
||||
class OutlineNode(BaseModel):
|
||||
"""A heading in a document's outline. `id` is the heading item's self_ref."""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
level: int
|
||||
page_numbers: list[int] = []
|
||||
children: list["OutlineNode"] = []
|
||||
|
||||
|
||||
class DocumentSection(BaseModel):
|
||||
"""One section's text in reading order, subsections included."""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
page_numbers: list[int] = []
|
||||
content: str
|
||||
|
||||
|
||||
class DocumentListResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -52,31 +52,16 @@ def decode_picture(data: bytes, self_ref: str) -> BinaryContent | None:
|
|||
return BinaryContent(data=data, media_type="image/png", identifier=self_ref)
|
||||
|
||||
|
||||
def build_image_content_from_results(
|
||||
results: list[SearchResult],
|
||||
include_collection: bool = False,
|
||||
exclude: AbstractSet[PictureKey] = frozenset(),
|
||||
) -> tuple[list[str | BinaryContent], set[PictureKey]]:
|
||||
"""Decode and validate picture bytes attached to search results, labelled.
|
||||
def collect_pictures(
|
||||
results: list[SearchResult], exclude: AbstractSet[PictureKey] = frozenset()
|
||||
) -> tuple[list[tuple[str | None, str | None, str, BinaryContent]], set[PictureKey]]:
|
||||
"""Every distinct, decodable picture attached to ``results``, in order.
|
||||
|
||||
Returns the labelled content and the ``PictureKey`` of every picture it
|
||||
emitted. Dedup keyed on ``PictureKey`` so the same picture in
|
||||
different chunks is sent once, and a copy in another collection is its
|
||||
own; ``exclude`` seeds that dedup with pictures already sent. Pictures that fail
|
||||
``PIL.Image.verify()`` are skipped — the model adapter renders one
|
||||
vision placeholder per ``BinaryContent``, so emitting one for an
|
||||
image the server can't decode leaves the processor with an
|
||||
off-by-one count.
|
||||
|
||||
Every picture is preceded by a line naming the result it belongs to.
|
||||
``ToolReturn.content`` reaches the model as a user-role message, so
|
||||
retrieved pictures are otherwise indistinguishable from ones the user
|
||||
attached, and models narrate them as part of the question: unlabelled,
|
||||
gemma4-26b answered about a figure from an unrelated document, and with a
|
||||
single note ahead of the batch it still called them "images in the prompt".
|
||||
The label also names the chunk to cite for a figure, which
|
||||
``BinaryContent.identifier`` cannot do — it does not survive serialization
|
||||
to the vision API.
|
||||
Returns ``(source, chunk_id, self_ref, picture)`` per picture and the
|
||||
``PictureKey`` of each. Dedup keyed on ``PictureKey`` so the same picture in
|
||||
different chunks is emitted once, and a copy in another collection is its
|
||||
own; ``exclude`` seeds that dedup with pictures already sent. Pictures that
|
||||
fail ``PIL.Image.verify()`` are skipped.
|
||||
"""
|
||||
collected: list[tuple[str | None, str | None, str, BinaryContent]] = []
|
||||
seen: set[PictureKey] = set(exclude)
|
||||
|
|
@ -94,7 +79,33 @@ def build_image_content_from_results(
|
|||
collected.append((result.source, result.chunk_id, self_ref, picture))
|
||||
seen.add(key)
|
||||
emitted.add(key)
|
||||
return collected, emitted
|
||||
|
||||
|
||||
def build_image_content_from_results(
|
||||
results: list[SearchResult],
|
||||
include_collection: bool = False,
|
||||
exclude: AbstractSet[PictureKey] = frozenset(),
|
||||
) -> tuple[list[str | BinaryContent], set[PictureKey]]:
|
||||
"""Decode and validate picture bytes attached to search results, labelled.
|
||||
|
||||
Returns the labelled content and the ``PictureKey`` of every picture it
|
||||
emitted, as ``collect_pictures`` decides them. An undecodable picture is
|
||||
skipped because the model adapter renders one vision placeholder per
|
||||
``BinaryContent``, so emitting one for an image the server can't decode
|
||||
leaves the processor with an off-by-one count.
|
||||
|
||||
Every picture is preceded by a line naming the result it belongs to.
|
||||
``ToolReturn.content`` reaches the model as a user-role message, so
|
||||
retrieved pictures are otherwise indistinguishable from ones the user
|
||||
attached, and models narrate them as part of the question: unlabelled,
|
||||
gemma4-26b answered about a figure from an unrelated document, and with a
|
||||
single note ahead of the batch it still called them "images in the prompt".
|
||||
The label also names the chunk to cite for a figure, which
|
||||
``BinaryContent.identifier`` cannot do — it does not survive serialization
|
||||
to the vision API.
|
||||
"""
|
||||
collected, emitted = collect_pictures(results, exclude)
|
||||
content: list[str | BinaryContent] = []
|
||||
total = len(collected)
|
||||
for position, (source, chunk_id, self_ref, picture) in enumerate(collected, 1):
|
||||
|
|
|
|||
|
|
@ -393,43 +393,6 @@ def _citation_label(c: "Citation") -> str:
|
|||
return c.document_title or c.document_uri
|
||||
|
||||
|
||||
def format_citations(citations: "list[Citation]") -> str:
|
||||
"""Format citations as plain text with preserved formatting.
|
||||
|
||||
Used by things like the MCP server where Rich renderables are not available.
|
||||
Pictures referenced by the chunk are surfaced as ``[Figure: <ref>]`` markers.
|
||||
"""
|
||||
if not citations:
|
||||
return ""
|
||||
|
||||
lines = ["## Citations\n"]
|
||||
|
||||
for i, c in enumerate(citations):
|
||||
idx = c.index if c.index is not None else (i + 1)
|
||||
title = c.document_title or c.document_uri
|
||||
header = f"[{idx}] {title}"
|
||||
|
||||
location_parts = []
|
||||
pages = _citation_pages(c)
|
||||
if pages:
|
||||
location_parts.append(pages)
|
||||
section = _citation_section(c)
|
||||
if section:
|
||||
location_parts.append(f"Section: {section}")
|
||||
|
||||
source = c.document_uri
|
||||
if location_parts:
|
||||
source += f" - {', '.join(location_parts)}"
|
||||
|
||||
lines.append(f"{header} {source}")
|
||||
for ref in c.picture_refs:
|
||||
lines.append(f"[Figure: {ref}]")
|
||||
lines.append(c.content)
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def truncated(text: str, limit: int) -> str:
|
||||
"""The first `limit` characters of `text`, with `…` appended when anything
|
||||
was dropped. A cut result is `limit` characters plus the mark."""
|
||||
|
|
|
|||
|
|
@ -40,12 +40,12 @@ dependencies = [
|
|||
"docling-core>=2.82.0,<3.0.0",
|
||||
"httpx>=0.28.1",
|
||||
"jinja2>=3.1.0",
|
||||
"fastmcp>=3.3.0",
|
||||
"fastmcp>=4.0.2,<5.0.0",
|
||||
"lancedb==0.37.1",
|
||||
"pathspec>=1.0.4",
|
||||
"pydantic>=2.12.5",
|
||||
"pydantic-ai-slim[openai,logfire,ag-ui]>=2.18.0,<3.0.0",
|
||||
"pydantic-monty>=0.0.19",
|
||||
"pydantic-monty>=0.0.23",
|
||||
"pypdfium2>=5.0",
|
||||
"python-dotenv>=1.2.2",
|
||||
"pyyaml>=6.0.3",
|
||||
|
|
|
|||
12
plugins/haiku-rag/.claude-plugin/plugin.json
Normal file
12
plugins/haiku-rag/.claude-plugin/plugin.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "haiku-rag",
|
||||
"version": "0.82.1",
|
||||
"description": "Search, read and analyze your haiku.rag knowledge base from Claude Code.",
|
||||
"author": {
|
||||
"name": "Yiorgis Gozadinos",
|
||||
"email": "ggozadinos@gmail.com"
|
||||
},
|
||||
"homepage": "https://ggozad.github.io/haiku.rag/mcp/",
|
||||
"repository": "https://github.com/ggozad/haiku.rag",
|
||||
"license": "MIT"
|
||||
}
|
||||
26
plugins/haiku-rag/.codex-plugin/plugin.json
Normal file
26
plugins/haiku-rag/.codex-plugin/plugin.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "haiku-rag",
|
||||
"version": "0.82.1",
|
||||
"description": "Search, read and analyze your haiku.rag knowledge base from Codex.",
|
||||
"author": {
|
||||
"name": "Yiorgis Gozadinos",
|
||||
"email": "ggozadinos@gmail.com",
|
||||
"url": "https://github.com/ggozad"
|
||||
},
|
||||
"homepage": "https://ggozad.github.io/haiku.rag/mcp/",
|
||||
"repository": "https://github.com/ggozad/haiku.rag",
|
||||
"license": "MIT",
|
||||
"keywords": ["rag", "knowledge-base", "search", "documents", "mcp"],
|
||||
"skills": "./skills/",
|
||||
"mcpServers": "./.mcp.json",
|
||||
"interface": {
|
||||
"displayName": "haiku.rag",
|
||||
"shortDescription": "Search and analyze your haiku.rag knowledge base",
|
||||
"longDescription": "Search, read, and compute over documents in your local haiku.rag knowledge base through MCP tools.",
|
||||
"developerName": "Yiorgis Gozadinos",
|
||||
"category": "Productivity",
|
||||
"capabilities": ["Interactive", "Read"],
|
||||
"websiteURL": "https://ggozad.github.io/haiku.rag/",
|
||||
"defaultPrompt": "Search my haiku.rag knowledge base and cite the relevant documents."
|
||||
}
|
||||
}
|
||||
8
plugins/haiku-rag/.mcp.json
Normal file
8
plugins/haiku-rag/.mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"haiku-rag": {
|
||||
"command": "haiku-rag",
|
||||
"args": ["mcp", "--stdio"]
|
||||
}
|
||||
}
|
||||
}
|
||||
77
plugins/haiku-rag/skills/haiku-rag/SKILL.md
Normal file
77
plugins/haiku-rag/skills/haiku-rag/SKILL.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
---
|
||||
name: haiku-rag
|
||||
description: Search, read and compute over the user's haiku.rag knowledge base
|
||||
through the haiku-rag MCP tools. Use whenever a request could be answered
|
||||
from the user's ingested documents, when asked to find, look up, check or
|
||||
cite something in their documents or knowledge base, or when the question is
|
||||
about the user's own material rather than general knowledge.
|
||||
compatibility: Requires the haiku-rag MCP server to be registered in the client.
|
||||
allowed-tools:
|
||||
- mcp__plugin_haiku-rag_haiku-rag__search_documents
|
||||
- mcp__plugin_haiku-rag_haiku-rag__search_documents_by_image
|
||||
- mcp__plugin_haiku-rag_haiku-rag__get_document
|
||||
- mcp__plugin_haiku-rag_haiku-rag__get_document_outline
|
||||
- mcp__plugin_haiku-rag_haiku-rag__get_document_section
|
||||
- mcp__plugin_haiku-rag_haiku-rag__list_documents
|
||||
- mcp__plugin_haiku-rag_haiku-rag__execute_code
|
||||
---
|
||||
|
||||
# Working with the knowledge base
|
||||
|
||||
Check the knowledge base before answering from memory whenever the question
|
||||
could be about the user's documents. Say so when it has nothing relevant.
|
||||
|
||||
## Find
|
||||
|
||||
`search_documents` is the first call. Results come best first with the document
|
||||
title, section headings, the matched chunk's metadata when it has any, and the
|
||||
passage in its section. Pictures in the results arrive as images: answer
|
||||
figure questions from them. `filter` restricts which documents are searched,
|
||||
`limit` how many results come back. If it misses, rephrase once or narrow with
|
||||
a filter before concluding the material is not there. When the question is
|
||||
about an image rather than words and the server offers
|
||||
`search_documents_by_image`, it takes the image as the query.
|
||||
|
||||
## Read
|
||||
|
||||
Every search result shows its `Document ID` (and `Collection` when there are
|
||||
several); pass them to the read tools. `get_document` returns a document's
|
||||
whole text in reading order. For a long one, `get_document_outline` gives the
|
||||
heading tree with page numbers and `get_document_section` the text of one
|
||||
section, subsections included.
|
||||
|
||||
## Compute
|
||||
|
||||
`execute_code` runs a Python program on the server over the same documents.
|
||||
Under `/documents/{id}/` each has `metadata.json`, `content.txt`, `items.jsonl`,
|
||||
`chunks.jsonl` and `toc.json`, and the program can `await search(query)` and
|
||||
`await list_documents()`. Write code when the answer is a count, an aggregate, a
|
||||
comparison across many documents, a lookup by document or chunk metadata, or a
|
||||
pattern over whole documents: whatever search cannot rank. Each call is one
|
||||
program and variables do not carry over, so gather, compute and `print` a
|
||||
compact result in the same program. `filter` and `sources` select the documents
|
||||
it sees. For a known document's structure read its `toc.json` first; `search()`
|
||||
ranks across every document. Map a title or URI to an id with one
|
||||
`list_documents()` call rather than reading every `metadata.json`; the files
|
||||
carry no `source`, so over several collections group by its rows. Answer and
|
||||
cite from what it printed.
|
||||
|
||||
## Explore
|
||||
|
||||
`list_documents` shows what is stored: titles, URIs and metadata. It is how you
|
||||
learn what a filter can match.
|
||||
|
||||
## Filters
|
||||
|
||||
A SQL WHERE clause over the document columns `id`, `uri`, `title`,
|
||||
`created_at`, `updated_at`, `metadata`. `metadata` is a JSON string, so match
|
||||
it with LIKE: `metadata LIKE '%"author": "Smith"%'`. Also `uri LIKE '%.pdf'`,
|
||||
`title = 'Q3 report'`.
|
||||
|
||||
## Results and citations
|
||||
|
||||
Rank is the signal; scores are not comparable across queries and are never
|
||||
confidence. Cite the document title or URI, the section heading and page
|
||||
numbers when present, and the matched chunk's metadata when it carries locators
|
||||
such as paragraph or footnote numbers. When results carry `source`, the server
|
||||
covers several collections: name it, and pass `sources` to search a subset.
|
||||
|
|
@ -2,7 +2,8 @@
|
|||
"""
|
||||
Version bumping script for haiku.rag workspace.
|
||||
|
||||
Updates version in all pyproject.toml files and CHANGELOG.md.
|
||||
Updates version in all pyproject.toml files, both plugin manifests, and
|
||||
CHANGELOG.md.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
|
@ -54,6 +55,19 @@ def update_example_dependencies(file_path: Path, new_version: str) -> None:
|
|||
print(f"✓ Updated example dependencies in {file_path.relative_to(Path.cwd())}")
|
||||
|
||||
|
||||
def update_plugin_version(file_path: Path, new_version: str) -> None:
|
||||
"""Update the version in a plugin manifest."""
|
||||
content = file_path.read_text()
|
||||
updated = re.sub(
|
||||
r'^(\s*"version": )"[^"]+"',
|
||||
rf'\1"{new_version}"',
|
||||
content,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
file_path.write_text(updated)
|
||||
print(f"✓ Updated {file_path.relative_to(Path.cwd())}")
|
||||
|
||||
|
||||
def update_changelog(changelog_path: Path, new_version: str) -> None:
|
||||
"""Update CHANGELOG.md with new version."""
|
||||
content = changelog_path.read_text()
|
||||
|
|
@ -122,10 +136,16 @@ def main():
|
|||
root / "app" / "backend" / "pyproject.toml",
|
||||
]
|
||||
|
||||
plugin_files = [
|
||||
root / "plugins" / "haiku-rag" / ".claude-plugin" / "plugin.json",
|
||||
root / "plugins" / "haiku-rag" / ".codex-plugin" / "plugin.json",
|
||||
]
|
||||
changelog_file = root / "CHANGELOG.md"
|
||||
|
||||
# Check all files exist
|
||||
for file in pyproject_files + example_pyproject_files + [changelog_file]:
|
||||
for file in (
|
||||
pyproject_files + example_pyproject_files + plugin_files + [changelog_file]
|
||||
):
|
||||
if not file.exists():
|
||||
print(f"Error: {file} not found")
|
||||
sys.exit(1)
|
||||
|
|
@ -155,6 +175,9 @@ def main():
|
|||
for file in example_pyproject_files:
|
||||
update_example_dependencies(file, new_version)
|
||||
|
||||
for file in plugin_files:
|
||||
update_plugin_version(file, new_version)
|
||||
|
||||
# Update CHANGELOG.md
|
||||
update_changelog(changelog_file, new_version)
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,18 @@ def vcr_cassette_dir():
|
|||
class TestSandboxBasics:
|
||||
"""Test basic sandbox functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_documented_modules_import(self, sandbox):
|
||||
"""The modules the instructions and the MCP description promise."""
|
||||
result = await sandbox.execute(
|
||||
"import json, re, math, pathlib, datetime\n"
|
||||
"import collections, itertools, functools, dataclasses\n"
|
||||
"print(collections.Counter('aab').most_common(1),"
|
||||
" list(itertools.islice(itertools.count(), 2)))"
|
||||
)
|
||||
assert result.success, result.stderr
|
||||
assert "[('a', 2)] [0, 1]" in result.stdout
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_simple_code(self, sandbox):
|
||||
"""Test executing simple code in the sandbox."""
|
||||
|
|
@ -112,6 +124,41 @@ class TestSandboxListDocuments:
|
|||
assert "Test Document" in result.stdout
|
||||
assert temp_db_path.stem in result.stdout
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_documents_carries_metadata(self, temp_db_path):
|
||||
"""Rows carry the document's metadata, so a corpus-wide pass over it is
|
||||
one call rather than a file read per document."""
|
||||
from docling_core.types.doc.document import DoclingDocument
|
||||
from docling_core.types.doc.labels import DocItemLabel
|
||||
|
||||
config = AppConfig()
|
||||
docling = DoclingDocument(name="d")
|
||||
docling.add_text(label=DocItemLabel.TEXT, text="Test content")
|
||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||
await client.import_document(
|
||||
docling,
|
||||
[
|
||||
Chunk(
|
||||
content="Test content",
|
||||
embedding=[0.1] * config.embeddings.model.vector_dim,
|
||||
order=0,
|
||||
)
|
||||
],
|
||||
uri="test://doc1",
|
||||
title="Test Document",
|
||||
metadata={"author": "Ada"},
|
||||
)
|
||||
|
||||
sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext())
|
||||
try:
|
||||
result = await sb.execute(
|
||||
"docs = await list_documents()\nprint(docs[0]['metadata']['author'])"
|
||||
)
|
||||
finally:
|
||||
await sb.close()
|
||||
assert result.success, result.stderr
|
||||
assert "Ada" in result.stdout
|
||||
|
||||
|
||||
class TestSandboxSearch:
|
||||
"""Test search function in sandbox."""
|
||||
|
|
@ -188,6 +235,51 @@ class TestSandboxSearch:
|
|||
assert "str" in result.stdout
|
||||
assert "True" in result.stdout
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_returns_the_matched_chunks_metadata(
|
||||
self, temp_db_path, monkeypatch
|
||||
):
|
||||
"""Results carry the stored metadata of the chunk that matched, custom
|
||||
keys included."""
|
||||
from docling_core.types.doc.document import DoclingDocument
|
||||
from docling_core.types.doc.labels import DocItemLabel
|
||||
|
||||
from haiku.rag.embeddings import EmbedderWrapper
|
||||
|
||||
config = AppConfig()
|
||||
dim = config.embeddings.model.vector_dim
|
||||
|
||||
async def embed_query(self, text):
|
||||
return [0.1] * dim
|
||||
|
||||
monkeypatch.setattr(EmbedderWrapper, "embed_query", embed_query)
|
||||
docling = DoclingDocument(name="d")
|
||||
docling.add_text(label=DocItemLabel.TEXT, text="Paragraph fourteen.")
|
||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||
await client.import_document(
|
||||
docling,
|
||||
[
|
||||
Chunk(
|
||||
content="Paragraph fourteen.",
|
||||
embedding=[0.1] * dim,
|
||||
order=0,
|
||||
metadata={"para_no": "14"},
|
||||
)
|
||||
],
|
||||
uri="test://paras",
|
||||
)
|
||||
|
||||
sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext())
|
||||
try:
|
||||
result = await sb.execute(
|
||||
"results = await search('fourteen', limit=1)\n"
|
||||
"print(results[0]['chunk_meta']['para_no'])"
|
||||
)
|
||||
finally:
|
||||
await sb.close()
|
||||
assert result.success, result.stderr
|
||||
assert "14" in result.stdout
|
||||
|
||||
|
||||
class TestSandboxExternalFunctionEdgeCases:
|
||||
"""Test edge cases in external function dispatch."""
|
||||
|
|
@ -240,6 +332,23 @@ class TestSandboxExternalFunctionEdgeCases:
|
|||
assert not result.success
|
||||
assert "external error" in result.stderr
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failing_search_keeps_its_message_for_the_program(
|
||||
self, sandbox, monkeypatch
|
||||
):
|
||||
"""A host-side failure inside search() reaches the program with its
|
||||
message, which the agent reads to repair its code."""
|
||||
|
||||
async def boom(self, *args, **kwargs):
|
||||
raise ValueError("failed at /secret/path")
|
||||
|
||||
monkeypatch.setattr(HaikuRAG, "search", boom)
|
||||
|
||||
result = await sandbox.execute("await search('hello')")
|
||||
|
||||
assert not result.success
|
||||
assert "ValueError: failed at /secret/path" in result.stderr
|
||||
|
||||
|
||||
class TestSandboxOutputTruncation:
|
||||
"""Test output truncation behavior."""
|
||||
|
|
@ -312,13 +421,14 @@ class TestSandboxVFS:
|
|||
@pytest.mark.asyncio
|
||||
@pytest.mark.vcr()
|
||||
async def test_metadata_json(self, temp_db_path):
|
||||
"""metadata.json contains document title and uri."""
|
||||
"""metadata.json contains document title, uri and stored metadata."""
|
||||
config = AppConfig()
|
||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||
doc = await client.create_document(
|
||||
content="Test content",
|
||||
uri="test://doc1",
|
||||
title="Test Document",
|
||||
metadata={"author": "Ada"},
|
||||
)
|
||||
|
||||
context = AnalysisContext()
|
||||
|
|
@ -328,11 +438,13 @@ class TestSandboxVFS:
|
|||
"import json\n"
|
||||
f"meta = json.loads(Path('/documents/{doc.id}/metadata.json').read_text())\n"
|
||||
"print(meta['title'])\n"
|
||||
"print(meta['uri'])"
|
||||
"print(meta['uri'])\n"
|
||||
"print(meta['metadata']['author'])"
|
||||
)
|
||||
assert result.success
|
||||
assert result.success, result.stderr
|
||||
assert "Test Document" in result.stdout
|
||||
assert "test://doc1" in result.stdout
|
||||
assert "Ada" in result.stdout
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.vcr()
|
||||
|
|
@ -386,6 +498,59 @@ class TestSandboxVFS:
|
|||
assert result.success
|
||||
assert result.stdout.count("True") == 6
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chunks_jsonl(self, temp_db_path):
|
||||
"""chunks.jsonl lists a document's chunks in order with their stored
|
||||
metadata; a chunk found by its metadata leads to its items through
|
||||
their chunk_ids."""
|
||||
from docling_core.types.doc.document import DoclingDocument
|
||||
from docling_core.types.doc.labels import DocItemLabel
|
||||
|
||||
config = AppConfig()
|
||||
dim = config.embeddings.model.vector_dim
|
||||
docling = DoclingDocument(name="d")
|
||||
docling.add_text(label=DocItemLabel.TEXT, text="Paragraph thirteen.")
|
||||
docling.add_text(label=DocItemLabel.TEXT, text="Paragraph fourteen.")
|
||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||
doc = await client.import_document(
|
||||
docling,
|
||||
[
|
||||
Chunk(
|
||||
content="Paragraph thirteen.",
|
||||
embedding=[0.1] * dim,
|
||||
order=0,
|
||||
metadata={"para_no": "13", "doc_item_refs": ["#/texts/0"]},
|
||||
),
|
||||
Chunk(
|
||||
content="Paragraph fourteen.",
|
||||
embedding=[0.1] * dim,
|
||||
order=1,
|
||||
metadata={"para_no": "14", "doc_item_refs": ["#/texts/1"]},
|
||||
),
|
||||
],
|
||||
uri="test://paras",
|
||||
)
|
||||
|
||||
sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext())
|
||||
try:
|
||||
result = await sb.execute(
|
||||
"from pathlib import Path\n"
|
||||
"import json\n"
|
||||
f"root = Path('/documents/{doc.id}')\n"
|
||||
"def rows(name):\n"
|
||||
" return [json.loads(l) for l in (root / name).read_text().strip().split('\\n')]\n"
|
||||
"chunks = rows('chunks.jsonl')\n"
|
||||
"print(len(chunks))\n"
|
||||
"hit = [c for c in chunks if c['metadata'].get('para_no') == '14']\n"
|
||||
"print(len(hit))\n"
|
||||
"items = rows('items.jsonl')\n"
|
||||
"print([i['text'] for i in items if hit[0]['chunk_id'] in i['chunk_ids']])"
|
||||
)
|
||||
finally:
|
||||
await sb.close()
|
||||
assert result.success, result.stderr
|
||||
assert result.stdout.splitlines() == ["2", "1", "['Paragraph fourteen.']"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.vcr()
|
||||
async def test_open_read(self, temp_db_path):
|
||||
|
|
@ -433,7 +598,8 @@ class TestSandboxVFS:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"filename", ["content.txt", "items.jsonl", "toc.json", "metadata.json"]
|
||||
"filename",
|
||||
["content.txt", "items.jsonl", "chunks.jsonl", "toc.json", "metadata.json"],
|
||||
)
|
||||
async def test_write_denied_for_every_document_file(self, temp_db_path, filename):
|
||||
"""Every file in the document VFS is read-only, metadata.json included."""
|
||||
|
|
@ -797,6 +963,61 @@ class TestSandboxReadDeadline:
|
|||
cannot check its duration budget while one is in flight. The sandbox
|
||||
enforces the budget itself, before each read."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_deadline_covers_reads_from_memory_and_in_code_calls(
|
||||
self, temp_db_path, monkeypatch
|
||||
):
|
||||
"""Once a call's time is up, a file served from memory and an in-code
|
||||
listing are refused like a database read. A slow first read spends the
|
||||
budget; the watchdog does not count time spent waiting on the host."""
|
||||
from docling_core.types.doc.document import DoclingDocument
|
||||
from docling_core.types.doc.labels import DocItemLabel
|
||||
|
||||
config = AppConfig()
|
||||
config.analysis.code_timeout = 1.0
|
||||
docling = DoclingDocument(name="d")
|
||||
docling.add_text(label=DocItemLabel.TEXT, text="Foxes and dogs.")
|
||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||
doc = await client.import_document(
|
||||
docling,
|
||||
[
|
||||
Chunk(
|
||||
content="Foxes and dogs.",
|
||||
embedding=[0.1] * config.embeddings.model.vector_dim,
|
||||
order=0,
|
||||
)
|
||||
],
|
||||
uri="test://deadline-paths",
|
||||
)
|
||||
repository = type(client.document_repository)
|
||||
|
||||
async def slow_content(self, *args, **kwargs):
|
||||
await asyncio.sleep(1.3)
|
||||
return "body"
|
||||
|
||||
monkeypatch.setattr(repository, "get_content", slow_content)
|
||||
sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext())
|
||||
try:
|
||||
result = await sb.execute(
|
||||
"from pathlib import Path\n"
|
||||
f"root = Path('/documents/{doc.id}')\n"
|
||||
"print(len((root / 'content.txt').read_text()))\n"
|
||||
"try:\n"
|
||||
" (root / 'metadata.json').read_text()\n"
|
||||
" print('static: read')\n"
|
||||
"except Exception as e:\n"
|
||||
" print('static:', type(e).__name__)\n"
|
||||
"await list_documents()\n"
|
||||
"print('listed')"
|
||||
)
|
||||
finally:
|
||||
await sb.close()
|
||||
|
||||
assert "static: TimeoutError" in result.stdout
|
||||
assert "listed" not in result.stdout
|
||||
assert not result.success
|
||||
assert "time limit exceeded" in result.stderr
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_after_deadline_raises_without_scheduling(self, sandbox):
|
||||
"""A read attempted past the deadline fails instead of querying."""
|
||||
|
|
@ -825,7 +1046,51 @@ class TestSandboxReadDeadline:
|
|||
|
||||
sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext())
|
||||
|
||||
assert sb._session_limits() == {"max_duration_secs": 15.0}
|
||||
limits = sb._session_limits()
|
||||
|
||||
assert limits["max_duration_secs"] == 15.0
|
||||
cap = limits["max_suspensions"]
|
||||
assert cap is not None
|
||||
assert cap >= 1_000_000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_program_may_read_more_than_a_thousand_times(self, temp_db_path):
|
||||
"""Monty caps host callbacks per checkout at 1000 unless told otherwise;
|
||||
a corpus-wide pass over documents reads far more than that."""
|
||||
from docling_core.types.doc.document import DoclingDocument
|
||||
from docling_core.types.doc.labels import DocItemLabel
|
||||
|
||||
config = AppConfig()
|
||||
docling = DoclingDocument(name="d")
|
||||
docling.add_text(label=DocItemLabel.TEXT, text="Foxes and dogs.")
|
||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||
doc = await client.import_document(
|
||||
docling,
|
||||
[
|
||||
Chunk(
|
||||
content="Foxes and dogs.",
|
||||
embedding=[0.1] * config.embeddings.model.vector_dim,
|
||||
order=0,
|
||||
)
|
||||
],
|
||||
uri="test://many-reads",
|
||||
)
|
||||
|
||||
sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext())
|
||||
try:
|
||||
result = await sb.execute(
|
||||
"from pathlib import Path\n"
|
||||
f"p = Path('/documents/{doc.id}/content.txt')\n"
|
||||
"n = 0\n"
|
||||
"for i in range(1100):\n"
|
||||
" n += len(p.read_text())\n"
|
||||
"print(n)"
|
||||
)
|
||||
finally:
|
||||
await sb.close()
|
||||
|
||||
assert result.success, result.stderr
|
||||
assert result.stdout.strip() == str(1100 * len("Foxes and dogs."))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refused_read_fails_the_execution(self, temp_db_path, monkeypatch):
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import pytest
|
|||
from haiku.rag.client import HaikuRAG
|
||||
from haiku.rag.config.models import AppConfig
|
||||
from haiku.rag.sandbox import AnalysisContext, Sandbox
|
||||
from haiku.rag.store.models.chunk import Chunk
|
||||
from haiku.rag.store.models.document import Document
|
||||
from haiku.rag.store.models.document_item import DocumentItem
|
||||
|
||||
|
|
@ -434,6 +435,66 @@ class TestVfsReadPaths:
|
|||
"nope",
|
||||
)
|
||||
|
||||
async def test_chunks_jsonl_lists_chunks_in_order_with_their_metadata(
|
||||
self, temp_db_path
|
||||
):
|
||||
"""One row per chunk, in chunk order, carrying the stored metadata as
|
||||
is; the second read of a document is served from the sandbox's cache."""
|
||||
config = AppConfig()
|
||||
dim = config.embeddings.model.vector_dim
|
||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||
doc_id = await _empty_doc(client, uri="test://paras", title="Paras")
|
||||
for order, para_no in enumerate(["13", "14"]):
|
||||
await client.chunk_repository.create(
|
||||
Chunk(
|
||||
document_id=doc_id,
|
||||
content=f"Paragraph {para_no}.",
|
||||
embedding=[0.1] * dim,
|
||||
order=order,
|
||||
metadata={"para_no": para_no, "doc_item_refs": []},
|
||||
)
|
||||
)
|
||||
|
||||
sandbox = Sandbox(temp_db_path, config, AnalysisContext())
|
||||
first = await _read_vfs_text(sandbox, f"/documents/{doc_id}/chunks.jsonl")
|
||||
rows = [json.loads(line) for line in first.split("\n")]
|
||||
|
||||
assert [row["metadata"]["para_no"] for row in rows] == ["13", "14"]
|
||||
assert all(set(row) == {"chunk_id", "metadata"} for row in rows)
|
||||
assert rows[0]["metadata"] == {"para_no": "13", "doc_item_refs": []}
|
||||
assert sandbox._chunks_jsonl_cache[doc_id] == first
|
||||
assert (
|
||||
await _read_vfs_text(sandbox, f"/documents/{doc_id}/chunks.jsonl") == first
|
||||
)
|
||||
|
||||
async def test_item_range_is_a_line_slice_into_items_jsonl(self, temp_db_path):
|
||||
"""`item_range` indexes lines of items.jsonl, as documented, not item
|
||||
positions: a gap in positions must not pull the next heading into a
|
||||
section."""
|
||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||
doc_id = await _empty_doc(client, uri="test://slice", title="Slice")
|
||||
items = [
|
||||
_header(doc_id, 0, 1, "Intro"),
|
||||
_para(doc_id, 1),
|
||||
_header(doc_id, 3, 1, "Methods"),
|
||||
_para(doc_id, 4),
|
||||
]
|
||||
await client.document_item_repository.create_items(doc_id, items)
|
||||
|
||||
sandbox = Sandbox(temp_db_path, AppConfig(), AnalysisContext())
|
||||
toc = await _read_toc(sandbox, doc_id)
|
||||
raw = await _read_vfs_text(sandbox, f"/documents/{doc_id}/items.jsonl")
|
||||
lines = raw.split("\n")
|
||||
|
||||
intro, methods = toc["tree"]
|
||||
assert intro["item_range"] == [0, 2]
|
||||
assert methods["item_range"] == [2, 4]
|
||||
start, end = intro["item_range"]
|
||||
assert [json.loads(line)["self_ref"] for line in lines[start:end]] == [
|
||||
"#/texts/0",
|
||||
"#/texts/1",
|
||||
]
|
||||
|
||||
async def test_toc_skips_gaps_in_item_positions(self, temp_db_path):
|
||||
"""Positions need not be contiguous — a heading's span may cover
|
||||
positions that carry no item."""
|
||||
|
|
|
|||
|
|
@ -31,10 +31,6 @@ def client():
|
|||
@pytest.fixture
|
||||
def app(tmp_path, client, monkeypatch):
|
||||
class StubHaikuRAG:
|
||||
# run_mcp passes db_path positionally; every other caller uses kwargs.
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def _covering(cls, *args, **kwargs):
|
||||
return cls()
|
||||
|
|
|
|||
41
tests/test_bump_version.py
Normal file
41
tests/test_bump_version.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"bump_version", Path(__file__).resolve().parents[1] / "scripts" / "bump_version.py"
|
||||
)
|
||||
assert _spec is not None and _spec.loader is not None
|
||||
bump_version = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(bump_version)
|
||||
|
||||
|
||||
def test_update_plugin_version_rewrites_only_the_version_field(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
manifest = tmp_path / "plugin.json"
|
||||
manifest.write_text(
|
||||
'{\n "name": "haiku-rag",\n "version": "0.1.0",\n "license": "MIT"\n}\n'
|
||||
)
|
||||
|
||||
bump_version.update_plugin_version(manifest, "0.2.0")
|
||||
|
||||
assert json.loads(manifest.read_text()) == {
|
||||
"name": "haiku-rag",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
}
|
||||
assert manifest.read_text().endswith("}\n")
|
||||
|
||||
|
||||
def test_the_shipped_plugin_manifests_carry_the_package_version():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
package_version = bump_version.get_current_version(
|
||||
root / "haiku_rag_slim" / "pyproject.toml"
|
||||
)
|
||||
for client in ("claude", "codex"):
|
||||
manifest = json.loads(
|
||||
(
|
||||
root / "plugins" / "haiku-rag" / f".{client}-plugin" / "plugin.json"
|
||||
).read_text()
|
||||
)
|
||||
assert manifest["version"] == package_version
|
||||
|
|
@ -264,6 +264,37 @@ def test_search_result_format_for_agent_omits_chunk_meta():
|
|||
assert "para_no" not in formatted
|
||||
|
||||
|
||||
def test_search_result_format_for_agent_chunk_meta_is_opt_in():
|
||||
"""A caller that asks sees the chunk's own metadata, never the structural
|
||||
keys haiku.rag stores beside it."""
|
||||
result = SearchResult(
|
||||
content="Some content.",
|
||||
score=0.9,
|
||||
chunk_id="chunk-1",
|
||||
chunk_meta={
|
||||
"para_no": "12",
|
||||
"doc_item_refs": ["#/texts/0"],
|
||||
"page_numbers": [1],
|
||||
"headings": ["Intro"],
|
||||
"labels": ["paragraph"],
|
||||
},
|
||||
)
|
||||
|
||||
opted = result.format_for_agent(rank=1, total=1, include_chunk_meta=True)
|
||||
|
||||
assert "para_no" in opted
|
||||
assert "12" in opted
|
||||
assert "doc_item_refs" not in opted
|
||||
assert "#/texts/0" not in opted
|
||||
|
||||
structural_only = result.model_copy(
|
||||
update={"chunk_meta": {"doc_item_refs": ["#/texts/0"], "page_numbers": [1]}}
|
||||
)
|
||||
assert structural_only.format_for_agent(
|
||||
rank=1, total=1, include_chunk_meta=True
|
||||
) == structural_only.format_for_agent(rank=1, total=1)
|
||||
|
||||
|
||||
def test_search_result_format_for_agent_omits_document_meta():
|
||||
"""Document metadata is UI plumbing, never shown to the model."""
|
||||
result = SearchResult(
|
||||
|
|
@ -414,6 +445,17 @@ def test_search_result_format_for_agent_source_line(fields, expected_source):
|
|||
assert expected_source in result.format_for_agent()
|
||||
|
||||
|
||||
def test_search_result_format_for_agent_document_id_is_opt_in():
|
||||
"""The capabilities' rendering is unchanged; only a caller that asks gets
|
||||
the id it will fetch the document by."""
|
||||
result = SearchResult(content="x", score=0.5, chunk_id="c1", document_id="doc-1")
|
||||
|
||||
assert "Document ID" not in result.format_for_agent(rank=1, total=1)
|
||||
assert "Document ID: doc-1" in result.format_for_agent(
|
||||
rank=1, total=1, include_document_id=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"labels,expected",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -1050,6 +1050,21 @@ def test_mcp_without_stdio_leaves_the_transport_unset(app_stub):
|
|||
assert app_stub.run_mcp.call_args.kwargs["transport"] is None
|
||||
|
||||
|
||||
def test_mcp_covers_the_configured_set(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def create_app(db=None, *, covers_set=False):
|
||||
seen["covers_set"] = covers_set
|
||||
return AsyncMock()
|
||||
|
||||
monkeypatch.setattr("haiku.rag.cli.create_app", create_app)
|
||||
|
||||
result = runner.invoke(cli, ["mcp", "--stdio"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert seen["covers_set"] is True
|
||||
|
||||
|
||||
def test_version_flag_prints_the_version():
|
||||
result = runner.invoke(cli, ["--version"])
|
||||
|
||||
|
|
|
|||
1369
tests/test_mcp.py
1369
tests/test_mcp.py
File diff suppressed because it is too large
Load diff
|
|
@ -662,117 +662,6 @@ def test_format_bytes():
|
|||
assert format_bytes(1125899906842624) == "1.0 PB"
|
||||
|
||||
|
||||
# --- format_citations tests ---
|
||||
|
||||
|
||||
def test_format_citations_empty():
|
||||
from haiku.rag.utils import format_citations
|
||||
|
||||
assert format_citations([]) == ""
|
||||
|
||||
|
||||
def test_format_citations_with_citation():
|
||||
from haiku.rag.store.models.citation import Citation
|
||||
from haiku.rag.utils import format_citations
|
||||
|
||||
citation = Citation(
|
||||
document_id="doc1",
|
||||
chunk_id="chunk1",
|
||||
document_uri="test://doc",
|
||||
document_title="Test Doc",
|
||||
content="Some content",
|
||||
page_numbers=[1],
|
||||
headings=["Intro"],
|
||||
)
|
||||
result = format_citations([citation])
|
||||
assert "[1] Test Doc" in result
|
||||
assert "doc1" not in result
|
||||
assert "chunk1" not in result
|
||||
assert "test://doc" in result
|
||||
assert "p. 1" in result
|
||||
assert "Section: Intro" in result
|
||||
assert "Some content" in result
|
||||
|
||||
|
||||
def test_format_citations_multiple_pages():
|
||||
from haiku.rag.store.models.citation import Citation
|
||||
from haiku.rag.utils import format_citations
|
||||
|
||||
citation = Citation(
|
||||
document_id="doc1",
|
||||
chunk_id="chunk1",
|
||||
document_uri="test://doc",
|
||||
content="Content",
|
||||
page_numbers=[1, 2, 3],
|
||||
)
|
||||
result = format_citations([citation])
|
||||
assert "[1] test://doc" in result
|
||||
assert "pp. 1-3" in result
|
||||
# No title: the URI stands in, and the document id never leaks.
|
||||
assert "doc1" not in result
|
||||
|
||||
|
||||
def test_format_citations_with_index():
|
||||
from haiku.rag.store.models.citation import Citation
|
||||
from haiku.rag.utils import format_citations
|
||||
|
||||
citation = Citation(
|
||||
index=5,
|
||||
document_id="doc1",
|
||||
chunk_id="chunk1",
|
||||
document_uri="test://doc",
|
||||
document_title="Test Doc",
|
||||
content="Content",
|
||||
)
|
||||
result = format_citations([citation])
|
||||
assert "[5] Test Doc" in result
|
||||
|
||||
|
||||
def test_format_citations_sequential_indices():
|
||||
from haiku.rag.store.models.citation import Citation
|
||||
from haiku.rag.utils import format_citations
|
||||
|
||||
citations = [
|
||||
Citation(
|
||||
document_id="doc1",
|
||||
chunk_id="chunk1",
|
||||
document_uri="test://doc1",
|
||||
document_title="First",
|
||||
content="Content 1",
|
||||
),
|
||||
Citation(
|
||||
document_id="doc2",
|
||||
chunk_id="chunk2",
|
||||
document_uri="test://doc2",
|
||||
document_title="Second",
|
||||
content="Content 2",
|
||||
),
|
||||
]
|
||||
result = format_citations(citations)
|
||||
assert "[1] First" in result
|
||||
assert "[2] Second" in result
|
||||
|
||||
|
||||
# --- format_citations tests (pictures) ---
|
||||
|
||||
|
||||
def test_format_citations_picture_refs_render_as_markers():
|
||||
from haiku.rag.store.models.citation import Citation
|
||||
from haiku.rag.utils import format_citations
|
||||
|
||||
citation = Citation(
|
||||
document_id="doc1",
|
||||
chunk_id="chunk1",
|
||||
document_uri="test://doc",
|
||||
document_title="Test Doc",
|
||||
content="text body",
|
||||
picture_refs=["#/pictures/0", "#/pictures/3"],
|
||||
)
|
||||
result = format_citations([citation])
|
||||
assert "[Figure: #/pictures/0]" in result
|
||||
assert "[Figure: #/pictures/3]" in result
|
||||
|
||||
|
||||
# --- format_citations_rich tests ---
|
||||
|
||||
|
||||
|
|
@ -813,6 +702,22 @@ async def test_format_citations_rich_header_and_footer():
|
|||
assert "chunk: chunk-uuid-1" in output
|
||||
|
||||
|
||||
async def test_format_citations_rich_names_a_single_page():
|
||||
from haiku.rag.store.models.citation import Citation
|
||||
from haiku.rag.utils import format_citations_rich
|
||||
|
||||
citation = Citation(
|
||||
document_id="doc1",
|
||||
chunk_id="chunk1",
|
||||
document_uri="test://doc",
|
||||
content="Body",
|
||||
page_numbers=[3],
|
||||
)
|
||||
output = _render_rich(await format_citations_rich([citation]))
|
||||
assert "p. 3" in output
|
||||
assert "pp." not in output
|
||||
|
||||
|
||||
async def test_format_citations_rich_names_the_database_when_federating():
|
||||
"""Across databases, a citation has to say which one it came from."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
|
|
|||
204
uv.lock
204
uv.lock
|
|
@ -1209,21 +1209,22 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "fastmcp"
|
||||
version = "3.3.1"
|
||||
version = "4.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "fastmcp-slim", extra = ["client", "server"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/a9/5c5a01b6abd5346bf60b97cfd29e4a86661940c27dd562bfcda07fd03519/fastmcp-3.3.1.tar.gz", hash = "sha256:979362ea557de42a5f40342563c7e4b236bcc8e7cd192715f50030695d1a71cd", size = 28681699, upload-time = "2026-05-15T15:50:39.673Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/1c/981a1854f91a08872f4b8b9a627d5d751cafc1340d29b21b747f0b520b0a/fastmcp-4.0.2.tar.gz", hash = "sha256:60d5c5ead3b6a117bfada5c0f95fe5c1aba53d1577079ecbdf42eeff0cd9b931", size = 42306015, upload-time = "2026-09-02T23:28:08.386Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/11/6b1bdada6ccfe647d615ae63f9106f8136aec17971e9361546af01c7d38e/fastmcp-3.3.1-py3-none-any.whl", hash = "sha256:862440c5c4d281363a5995eee59d77f0f7cac1f18869038729cecf03b02fc522", size = 7903, upload-time = "2026-05-15T15:50:36.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/3f/b97cfb92e0d6db8232c67c258117cd0dd9def86c8b472270bd7196d5cd9d/fastmcp-4.0.2-py3-none-any.whl", hash = "sha256:9075e64a94634ad660971ed14374c87be06f2a16a921028ca87987e6aa2f3bfa", size = 8078, upload-time = "2026-09-02T23:28:03.777Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastmcp-slim"
|
||||
version = "3.3.1"
|
||||
version = "4.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mcp-types" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "pydantic", extra = ["email"] },
|
||||
{ name = "pydantic-settings" },
|
||||
|
|
@ -1231,26 +1232,28 @@ dependencies = [
|
|||
{ name = "rich" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/a0/627103e517e1d0d6f1eec633d5662d13e776f01b45ad188e4f5f7478b438/fastmcp_slim-3.3.1.tar.gz", hash = "sha256:0957835fc59452e143ab2f4b7836d2d2df9b2d9958408edc79ba8b56232b2a88", size = 567007, upload-time = "2026-05-15T15:50:10.426Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/7d/c2597734e3a0859d62c9d8f6f35067d1e296537512e280db3af19204be64/fastmcp_slim-4.0.2.tar.gz", hash = "sha256:86b99bdcb872b52d964c79bc6d43ce79f40ed5538b589d102792b4a7cf3947f4", size = 684052, upload-time = "2026-09-02T23:27:39.868Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/ee/97047f4cc2d7b1d46670d08d8ad01a96e7a748cc01c0b4b351ad8eddbc7a/fastmcp_slim-3.3.1-py3-none-any.whl", hash = "sha256:6cf1c2d77e3adb0d409d6825ed6b0b2a999062973e00b8eea03bd48bf9b4c043", size = 738644, upload-time = "2026-05-15T15:50:08.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c0/c022eba3a25ebb56111de1b5f76fbfca81925def58880464263582acfcd8/fastmcp_slim-4.0.2-py3-none-any.whl", hash = "sha256:6bd5b5885628f73263fa2247ea1d26e4a514499a6e079ee3e340cd03a7fe5ed8", size = 858100, upload-time = "2026-09-02T23:27:38.459Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
client = [
|
||||
{ name = "authlib" },
|
||||
{ name = "exceptiongroup" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx2" },
|
||||
{ name = "mcp" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
server = [
|
||||
{ name = "authlib" },
|
||||
{ name = "cyclopts" },
|
||||
{ name = "exceptiongroup" },
|
||||
{ name = "griffelib" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx2" },
|
||||
{ name = "joserfc" },
|
||||
{ name = "jsonref" },
|
||||
{ name = "jsonschema-path" },
|
||||
{ name = "mcp" },
|
||||
|
|
@ -1261,6 +1264,7 @@ server = [
|
|||
{ name = "pyperclip" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "starlette" },
|
||||
{ name = "uncalled-for" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "watchfiles" },
|
||||
|
|
@ -1755,7 +1759,7 @@ requires-dist = [
|
|||
{ name = "docling", marker = "extra == 'docling'", specifier = ">=2.102.2,<3.0.0" },
|
||||
{ name = "docling-core", specifier = ">=2.82.0,<3.0.0" },
|
||||
{ name = "fastapi", marker = "extra == 'ingester'", specifier = ">=0.125" },
|
||||
{ name = "fastmcp", specifier = ">=3.3.0" },
|
||||
{ name = "fastmcp", specifier = ">=4.0.2,<5.0.0" },
|
||||
{ name = "haiku-rag-slim", extras = ["s3"], marker = "extra == 'ingester'", editable = "haiku_rag_slim" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "jinja2", specifier = ">=3.1.0" },
|
||||
|
|
@ -1772,7 +1776,7 @@ requires-dist = [
|
|||
{ name = "pydantic-ai-slim", extras = ["mistral"], marker = "extra == 'mistral'" },
|
||||
{ name = "pydantic-ai-slim", extras = ["openai", "logfire", "ag-ui"], specifier = ">=2.18.0,<3.0.0" },
|
||||
{ name = "pydantic-ai-slim", extras = ["voyageai"], marker = "extra == 'voyageai'" },
|
||||
{ name = "pydantic-monty", specifier = ">=0.0.19" },
|
||||
{ name = "pydantic-monty", specifier = ">=0.0.23" },
|
||||
{ name = "pypdfium2", specifier = ">=5.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||
|
|
@ -1887,15 +1891,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx2"
|
||||
version = "2.8.0"
|
||||
|
|
@ -2590,15 +2585,15 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.28.1"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx-sse" },
|
||||
{ name = "httpx2" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "mcp-types" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||
|
|
@ -2608,9 +2603,22 @@ dependencies = [
|
|||
{ name = "typing-inspection" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d4/6e/21fb8e5d579dbe21d96ea4d5034200d46d8bdf2261053b5bd041f3c2f612/mcp-2.1.1.tar.gz", hash = "sha256:50b7ba1ebbe117008ea7bdd288234043e69c20b403d6851d19661e6d431a75ef", size = 3984589, upload-time = "2026-08-25T16:14:02.376Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/af/8644cc5fa26a59afd2df2e98eeb19e72926887fa4b7441aba4ff661140db/mcp-2.1.1-py3-none-any.whl", hash = "sha256:1c6c31c5d6471c58db76af3af8af67f46d11d01f0a59077d0a308cbdb3d3e915", size = 357912, upload-time = "2026-08-25T16:13:59.024Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcp-types"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6a/dd/1c4417dc0b722c23a1669032d5f044e41170fe5d4773b488a50fcce98c32/mcp_types-2.1.1.tar.gz", hash = "sha256:77dcbe48fba73cca71a673f2646a5f037a017b7a0a07ac89cec1113028890eda", size = 66674, upload-time = "2026-08-25T16:14:03.861Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/d0/242e63c510f4a17381f55b1549a3f94f5687a0595984febd2b6f87a687a0/mcp_types-2.1.1-py3-none-any.whl", hash = "sha256:26f9f7f03f2a5730717a5b98e2ab7eb640ac352d05a00cdc725c311864778295", size = 69656, upload-time = "2026-08-25T16:14:00.667Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3983,94 +3991,82 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pydantic-monty"
|
||||
version = "0.0.19"
|
||||
version = "0.0.23"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic-monty-client" },
|
||||
{ name = "pydantic-monty-runtime" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/01/cd7927f51500a13c1661e2db6488f6ff668194c4378d0fb218928892aaba/pydantic_monty-0.0.23.tar.gz", hash = "sha256:ee674b81ed12f81cfbe0db210fe5e803c754d0682634331931f4d70d5742058d", size = 6713, upload-time = "2026-09-05T19:26:03.24Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/b3/a259a600df30a54ee9e576c90bb7cb6c2b2d4750774c91748922cc202c5e/pydantic_monty-0.0.23-py3-none-any.whl", hash = "sha256:cddcf7d4d7dd163b56e4411f450ea0244a7205988c77cd85441ec89fedaab91a", size = 6302, upload-time = "2026-09-05T19:24:01.829Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-monty-client"
|
||||
version = "0.0.23"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/f8/c04df414086488834d102ab85cf8172c114108f842fb142ac92a490213fd/pydantic_monty-0.0.19.tar.gz", hash = "sha256:f3f9e256058b4085349dd4ad347795d5203a8310e9eaad9a2bff93890ac5b86d", size = 1484032, upload-time = "2026-07-24T10:00:13.194Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/29/e9/dcbf9a90ccd195be0db5257c2234d002d551d47f9d8987543eddd3095812/pydantic_monty_client-0.0.23.tar.gz", hash = "sha256:8b31f75afebb60c0416c869c8d6667a75e84b0f05b7a0b44e51b77ef98f294d7", size = 1983536, upload-time = "2026-09-05T19:26:04.167Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/bf/152bbb3315dfa46d4e4aae71779230e50c67a34d859a3470fd75c01b795c/pydantic_monty-0.0.19-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b073e64edfd62cca918d792d6fe559512472f981f949e53a7aec673201f5f554", size = 2492733, upload-time = "2026-07-24T09:56:49.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/16/9d37f1bf94c47c593a06ecb918bb64a2c8a38af24d6fa2b0d0e031938edf/pydantic_monty-0.0.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5825ae6c40270166f0f31b6e62d59b0fe47201d12b1be5842efc22d3b7dadcee", size = 2234610, upload-time = "2026-07-24T09:56:51.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/4f/31732f4b9c2b6574eb564e420593b9be3f80d92440211970fab23e882bc5/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5b6821c0035ff2c02cf0f37823fb905890f7238ce923bbc3ca937740f9554836", size = 2303116, upload-time = "2026-07-24T09:56:52.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/2c/c21e6179361100dd8b9ad410df775d176a29e0b85f2ffc3144fd29840ee9/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_armv7l.whl", hash = "sha256:5fb4514d99a10e304237a82baeb6072e934ce8462dcd5fb5c3fea0ee0ef16aeb", size = 2007947, upload-time = "2026-07-24T09:56:54.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/f0/ad64f4894334499f689bdce7e5b5dda6b680d98989424092dcbf21666564/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:2b0f154ac2e6450befa337a99a8519e2f1195cc59f88bd73d780067ace0c4c97", size = 2151468, upload-time = "2026-07-24T09:56:55.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/97/4ff5f9170e4865ec28fb152bc6ebaa8bd1873e695ee98af2103607ba42e8/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:6fcb4e3a312397432f7c5a0aa04a765670d9f37f4e23f37cb580b3faa2f218b1", size = 2300164, upload-time = "2026-07-24T09:56:57.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/b1/c9bfb6708bc5d9f6b040db46156c6e3f16de68ab22f07e2de4f25782414b/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:955d308171ba0260f75d2dede18c46d9732647f94b8f7fba3076bb539f155dc8", size = 2164984, upload-time = "2026-07-24T09:56:58.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/2d/8c1492216632f53e229cb2886c7fd09613d5990f4856f93f7ae0364da9bc/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:3d7e1a6cc1977b24e01b5322888a5ba3f05b112a04a1646ba51f2c34c562a3d9", size = 2357823, upload-time = "2026-07-24T09:57:00.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/cc/ae4adaaf343de00748fc3598402c1523e48db7094a9c1e0fa26177699440/pydantic_monty-0.0.19-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4904785a34f71f59aa1eb6dc081bafd09e4caa2f028cd379aa3aa846b8a1c27d", size = 2497387, upload-time = "2026-07-24T09:57:01.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/09/eaef87ed6cbffed9c720dd3cb850e748b0aab11f5ec1ecffb56250973f7d/pydantic_monty-0.0.19-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ce15a5326e24cf7045ec9c1456ddb92abd09df91708771c3ae5e72d8e6374c81", size = 2738391, upload-time = "2026-07-24T09:57:03.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/af/58be6fd6ea87e27bd57435013ca1f63d645a0c990c2f23708bcdd048af24/pydantic_monty-0.0.19-cp312-cp312-win32.whl", hash = "sha256:600eb259415e8b2dfef4be38d030c945b3fbb4fb85e727cb97131e7329ab017f", size = 1908274, upload-time = "2026-07-24T09:57:05.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/b7/1cb54e43113cb69c40fb765cfee3be1c222d81153b432131f50508569aee/pydantic_monty-0.0.19-cp312-cp312-win_amd64.whl", hash = "sha256:2c98b1c99994f92ab487a762b107067ab70036f64231e05aa3f6d2b16018688e", size = 2111335, upload-time = "2026-07-24T09:57:06.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/17/0926da051f34ccaa45bf528777dc99e5ea611669cdd7b715be1e086c1fe0/pydantic_monty-0.0.19-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:c01fb1162cf87dbf145b875450eabfde6b35b26f27ed63468398cb4c37732064", size = 2496669, upload-time = "2026-07-24T09:57:07.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/ba/c38f79e24971b4d2205ee156df6e5c6ccdcc720152f54a956cc26e7488b0/pydantic_monty-0.0.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e97dc97de32410003fb5517b72ab47799b4546a3ab48a75e60260cf73734da76", size = 2234599, upload-time = "2026-07-24T09:57:09.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/89/fb2ed1677ad2c3e2801aa119fdc280d1c64f3480e1015811e0942c9a7d20/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:06318e6add780f66259067829ab16062ce7a556dba0884372a881881b4a7c3bf", size = 2305696, upload-time = "2026-07-24T09:57:10.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/ec/e36ff1c2c97f46420d57680199e7ae2e1eb306d2cfda22be2448e53e7484/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_armv7l.whl", hash = "sha256:5a765a36141bbeb074d89b424d5488bed4e358c603c72606cc9d81ee44d83de2", size = 2007600, upload-time = "2026-07-24T09:57:12.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/88/b47670d3e28f99dc2f4c2686dc42233843dce1a607f3d3cc8a387f3b9b74/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:48f5ebc048779c854f993834586ba372df3b65500d1ed7f147023abc29aeb2c4", size = 2151382, upload-time = "2026-07-24T09:57:13.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/18/559d71fc66a769c22f6b2a8470515aa6e69a141ab617900fe28a806080b7/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2eb7502f93d8bb568d255fccca95e3b9daca05bb674b9c5323fcba14d088932c", size = 2302643, upload-time = "2026-07-24T09:57:15.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/b4/171be42e2ec211bd01fe4be7b6de9ab0c346081edc33830f9f9b781341ab/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:608994600a839dd863940ac84810c48d378390a4fea8c77e5145feb84037e610", size = 2169103, upload-time = "2026-07-24T09:57:16.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/ce/8c47253c8f3f528f0fa2d87dde85623dc3f211189a372e2d69cb6ad896b1/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:13a652f9dffa6d25ea458fec83108f60f29682caf42cecef91955b5b8cb04365", size = 2358155, upload-time = "2026-07-24T09:57:18.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/4e/239107b83bae3a20e4f5424f6c6f3f88bae1fd1e734603c298167985f8be/pydantic_monty-0.0.19-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6167c966db7d8d2fe03940f8da596de6dcd184dcf5cf6209f0a1a9af8792550d", size = 2500858, upload-time = "2026-07-24T09:57:19.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/55/02a7059f20e7198e511ac0b88a3957a2c01b8991326359b7c1bb590a09b8/pydantic_monty-0.0.19-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3102b44307d04f41897ae51d4daeab4fc9b5bf84a78c036781b488876ef998c3", size = 2742212, upload-time = "2026-07-24T09:57:21.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/28/b632fe0e8eeba3f2b4dbec6ebd4c9b569c20e9597007f2d0fbbf04101307/pydantic_monty-0.0.19-cp313-cp313-win32.whl", hash = "sha256:e43da52776796a894f40533a7e5a322e98d9aaf7d8f6fbb7dc21a0de60a93f41", size = 1908553, upload-time = "2026-07-24T09:57:22.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/d3/b90872f017871339ceb03e70fa4915ef8682128a476a66adffedfff874d8/pydantic_monty-0.0.19-cp313-cp313-win_amd64.whl", hash = "sha256:fd8c195875f8f44d55bc7d1d53c4e43248b184b3ac803d8131c92d4cc05a1aef", size = 2111345, upload-time = "2026-07-24T09:57:24.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/11/c2aed55502bfc9620837312f0e2fca7a3d4bc959824a66d81b72144d7256/pydantic_monty-0.0.19-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:2692dc4452937cf2200afd257297e9ac3ccff122b80aa7a69935e8275d684193", size = 2497017, upload-time = "2026-07-24T09:57:25.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/d0/d4b44a81c71308109cfa642a24b803057615ca1609530c5e66c376780efe/pydantic_monty-0.0.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5a4717f829b35c4bc5d9f6f52a52d19b90729bd494bcb69133bd4c0afcab9c76", size = 2247468, upload-time = "2026-07-24T09:57:27.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/89/52848dce3acbb1d58df34496db3c4718814cc39f6db01a5025bfdc12b530/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:99f7282213ad6ebf7daf149a88d1517e6328afbf6780180512b9de62f30d292b", size = 2306181, upload-time = "2026-07-24T09:57:29.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/05/f7791c79c7be2240c43a9287196ed49034f773e3f4158492f028e486ebc2/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_armv7l.whl", hash = "sha256:978788b1c56fa0c49927e0a35151f0a635ef2f8d947d16915541ff864d2112f5", size = 2008738, upload-time = "2026-07-24T09:57:30.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/a1/d714258eeb2583acaab2035834acc54ac6baa65bac456f897d901bc0c03a/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:8b821cac39deeb1abb2994d5a65f117ce26e55c586d0f570d425ff469ff48e3c", size = 2152275, upload-time = "2026-07-24T09:57:31.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/e5/cdb1fa761e992a489b07a17adb80c21bf99eabd1286ca62f9e73acda1f4b/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:b43c4ffa5651f0eca97458dc673d7952064ae5eb5b836d23967a7d41483bb8f4", size = 2303533, upload-time = "2026-07-24T09:57:33.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/9b/e6685cf82521e68e0dcb94e0c97a1a20410b1266fbce7008c0caa3484039/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:4054610601358943a3dd740a8d59a6812cc682e94d6903cb72baadae1ef5d2ac", size = 2169585, upload-time = "2026-07-24T09:57:34.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/f4/6f031a628d3de72d95bedbb18292ccf998d9a422aff58eedf37347a0b367/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:0b2a34c320968a3cef3d933737e99c932f13c55667315128f366afdaeea2be04", size = 2375171, upload-time = "2026-07-24T09:57:35.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/d3/4367bccdf2c06a977c0d5ddf816190d570a07199f011034df16d51c84723/pydantic_monty-0.0.19-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ffaf950f4284bb193a54f18fa4e3e8c225b5b52265813abffe492074e90c65fb", size = 2501475, upload-time = "2026-07-24T09:57:37.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/0e/7a0e1d5c016848afc9a8605aa4f16e6960d68806e775865b986aacaf8f88/pydantic_monty-0.0.19-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:bb1e5ee6762f9494bfb0f4cc316ad3e9a8c7917e3c984a23eb2e2322d401e5cb", size = 2742547, upload-time = "2026-07-24T09:57:39.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/f0/92f77cf088f1df72a5dbab109c8c0e82f7ddeb18e65835a8dffcf967bb4a/pydantic_monty-0.0.19-cp314-cp314-win32.whl", hash = "sha256:b68ef6503b39f2f014162e3d8e7f48b9722a43ceb7b6d7199dc6d545f4c37b34", size = 1907832, upload-time = "2026-07-24T09:57:40.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/3e/85e1a914f81659ea25c34916fdef27fcda2b00323dafb76cf2a5321f7c11/pydantic_monty-0.0.19-cp314-cp314-win_amd64.whl", hash = "sha256:14ef37b43c5bf90966ca51bcad0fae892a3c2546cd151fadc039e0a25ca74073", size = 2123187, upload-time = "2026-07-24T09:57:42.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f3/4e4975daee5ac4a86e95b08124e304cf7af774df50891b28479bb2c28ede/pydantic_monty_client-0.0.23-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7f309a03e75c418b34e469e547171059ebd1ebcd11971ed129b6905e2a7c057f", size = 3930126, upload-time = "2026-09-05T19:24:38.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/23/927d73209b509db9604606d88aaff90f56474bd9bdcfe9f39a873bfc945c/pydantic_monty_client-0.0.23-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e3efb7e2af60e17c16f3baa49ea3a2e2b90c88610666f294591b9819d3ccc69", size = 3702556, upload-time = "2026-09-05T19:24:39.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/c9/bfb5847d79e8bdeefbeea531b90f09e2c5f156bc361730975171a7e93146/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f065e8286378f26d29ceaeb2fb10712f202f45656967706675f93b3a17b99f78", size = 3728660, upload-time = "2026-09-05T19:24:41.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/9c/50af7e5a67876cb7c421401ff5f3885ae4fbd60324fea3c25e5fd31926af/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_armv7l.whl", hash = "sha256:d3e353536016174f9e0aca289f7d3a81a07ac7574e2a610902152e0ebf5feb5a", size = 3425561, upload-time = "2026-09-05T19:24:42.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e3/1d1a53f72576c190ea2f1a6c0fb5eeb722bea6901df8ddfba294ff49b08b/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:ef186bfe5344a84515cd51dc9ee5914084b1d616fad2409328d8edfbfb4d5ac3", size = 3633369, upload-time = "2026-09-05T19:24:44.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/fb/c5987eeff60e9d736a720ef1c6f3dadee39619f596517d8983a99ce4873a/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:7062196b773cc8b956b0dbece42203eb493270e0707e37447801255ad26f065e", size = 3843321, upload-time = "2026-09-05T19:24:45.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/43/cc1e93c74103a225dc39c30966ff3bec9973be0bbb338b080a55e4653bda/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:15ddde16a1398c601a1bb791e5c3a796ce83222b79ecb21bd7070e52aa007257", size = 3735663, upload-time = "2026-09-05T19:24:46.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/09/b7103e26fe6103e217c281f6581b918a2455f5a1600fc197b82010953b19/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b41dbca54254850a5ab68bed4c75992d6eeacd283265f7445c4ae705e29fed5a", size = 4006344, upload-time = "2026-09-05T19:24:48.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/71/6b21d3d52dcb1cdbeaa593baed170d2454a61bf5aad2692e6e3b3136c5f8/pydantic_monty_client-0.0.23-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1081ab9038004739ede448249d0b1c3d409fb8641ee9bad9ab5347d2bb415600", size = 3896737, upload-time = "2026-09-05T19:24:50.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/df/6a4ed64e3c21389b7e277a7fc98df1021767f9e27edd487b9c5cb1cc4448/pydantic_monty_client-0.0.23-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:182798245ed7ba503c49c9cf5f313876df9b81f57fcbfdf12efa1aa25be0fcd1", size = 4199695, upload-time = "2026-09-05T19:24:51.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/a7/5f166faeff1af270c1d139c90792fe9a2edf00b3c5e16d5014feb29c8d18/pydantic_monty_client-0.0.23-cp312-cp312-win32.whl", hash = "sha256:4d6354723ba6165d3856eac1439d2abed9d231730069c858219456e866a3bc16", size = 3288615, upload-time = "2026-09-05T19:24:53.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/d9/ff24b9dd6d00b65724962f21f10c5844492d1c8d101d86e601155f6c0425/pydantic_monty_client-0.0.23-cp312-cp312-win_amd64.whl", hash = "sha256:099173c188a063ebecd79ea5a2268d06f0031ec93dbaee03b62f4386e024f5dc", size = 3927504, upload-time = "2026-09-05T19:24:54.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/75/ca3594de58d97c1f34a5c8c6965a3acc69a73e7e616c8b99b8f6fc221c55/pydantic_monty_client-0.0.23-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:80fc4d09ade86f9d527dd79d8ab6f26e510d687c962ca0d499b391905f6efd9e", size = 3934648, upload-time = "2026-09-05T19:24:56.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/a2/ee39d943b7eb874a91df0fa0175c8153509322772bc5abace77f5e1763c1/pydantic_monty_client-0.0.23-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48f6cbb37c7a56dce5e1c4000e286447d5fb51dcf1e1cf5b69185922c43965e6", size = 3703648, upload-time = "2026-09-05T19:24:58.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/2b/67bc41a8a6c178bd840e1e6115a7af3b0289ba7b1f7b4d8f39a23d0abd43/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9b71eb5edd68ef9de22064dd5cf28feca283b5aef8f0ff00bb1308deba5e56bd", size = 3730495, upload-time = "2026-09-05T19:25:00.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/2a/0d94df69fef37fd3d4fbc0330e7f8442eb9f1bcb7f2ece69f90ef0b349b6/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_armv7l.whl", hash = "sha256:2a0aa01d8c1321b610b1c0633b7ffcf9dcbb7ecaa79e5bd6d355483b23440231", size = 3425587, upload-time = "2026-09-05T19:25:01.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/4f/61f1f175987a3ad595ee31b883ca1521f7defe3c733b7eb11f228b608a36/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:bfea6ada82fb43308833852980b147edca3a8de48bc2ce4e7cb08c6f1e13144a", size = 3633833, upload-time = "2026-09-05T19:25:03.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/71/173b79e38316b878534fb268bcb461730301ca2e7d656f71599f3be4cdb8/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:4ca25b02433751eb0735badd7004543f5eb799a7afc7354d0b0caeadc2e754d2", size = 3845772, upload-time = "2026-09-05T19:25:04.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/78/66707709d9d11222e04efd934486f22b1c44bdf4a62954516cfc746c5845/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:a4a7b40bb0d48b513a72977ccd9192d585c85bb470447a80a0a128a258d4c2c2", size = 3738982, upload-time = "2026-09-05T19:25:06.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/ac/d3ffac7ea991b490271df273213765408df4dec6943fed99ff36db1f0642/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6acfe4ffe75aac0b89f13ea1a527f388fc0d09643bca25d7c78f59b4fed5be25", size = 4011071, upload-time = "2026-09-05T19:25:07.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/26/c6599214e286e55eb7dcc8b43617b74878fe4d55f69b8e0d12abb0576019/pydantic_monty_client-0.0.23-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:538a8edd9afcc3461fe9b744beda644d1daca60fc76423e695795de57d939d4f", size = 3899476, upload-time = "2026-09-05T19:25:09.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/4d/e3179f513cb4e112d6acd1fafff1217eaf5567ea351f00dc61dd92a7d8ae/pydantic_monty_client-0.0.23-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef72181626512a8b7c3c6365d60e416c57bef219ee68f98475f7ec7c59db0db0", size = 4203793, upload-time = "2026-09-05T19:25:11.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/68/2889ff38031ab76eababba29eec92c7d4e9f23a84a58aad306f4609219d8/pydantic_monty_client-0.0.23-cp313-cp313-win32.whl", hash = "sha256:3509dce955db0b7ccbf8a2b458d6562e289295281b7910841e42a1de656a59dc", size = 3288733, upload-time = "2026-09-05T19:25:12.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/f6/e8344f4b3c4d0b8b37fee2b3d27676ae8ad35c5ef731c6ba48ae4012448c/pydantic_monty_client-0.0.23-cp313-cp313-win_amd64.whl", hash = "sha256:c661a74a80158460d633ac5510ccc5c80f77eb3c6c51a54a71d1d9a6108a6fdb", size = 3931122, upload-time = "2026-09-05T19:25:13.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/73/f4f36e465afd5346737a46b59c50e50ec133b9f05a0ba7f3adab0c5e8ab4/pydantic_monty_client-0.0.23-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:28bbf56a7f48dda2acf1cc11807daa50af8cc94e34e1c57fac5d16f99b3e7392", size = 3935351, upload-time = "2026-09-05T19:25:15.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/ab/0f6f9e9018c2107c9f0bf036167cadc2d1e7edf8275727c9333311f0f8d5/pydantic_monty_client-0.0.23-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff363c8a2afb25d6e716f2a67a6b350aaa864a278b0ba60a390231d4b351940b", size = 3704385, upload-time = "2026-09-05T19:25:16.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/71/2a0eded398b705c65a6d56cb8139c9fce4d878e01594570c98574734818a/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:94b056d3ee44ff3fa39be0790debba41f65fcb7db9d26461f039a01e49349c0a", size = 3730307, upload-time = "2026-09-05T19:25:18.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/c8/ba9fba363a8c96ef4635cf26f3d6d3715e7663823d166676bdf8f5bc06a3/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_armv7l.whl", hash = "sha256:f94396bdd59f74918871307fb7f63f4ed852ab770a91a70ed29d159b63823e97", size = 3426378, upload-time = "2026-09-05T19:25:19.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/6f/73d07f0b6f2608e155e121c573b4c5f42fa9dba672f4a5b827045ead3bd9/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:fbc150afe82ab299ceb80a9c308224f3d10ac6a1d98cc9214c24baede8fc42d5", size = 3635275, upload-time = "2026-09-05T19:25:21.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/e6/6a812e7bce36c48d87d84d0cda46002c86bcda8aa5fdf18667d48174815a/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:a080f9e5427b3223189c456337ec7866cb51c2033592c89f289ef457153800a8", size = 3846543, upload-time = "2026-09-05T19:25:23.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/06/19788b96be88fe6f58783b7a24225fb3129de7d4807c0837ae50c67172d6/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:ccae280613d9e15f8344cf82cda25d24dc7b598e191c4549c5f3118f327659b4", size = 3741406, upload-time = "2026-09-05T19:25:24.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/e4/306c0715579eb2ecf63166470535951d11907b162ac8d54cbdcea8eb479a/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:f41b870c7ce2e0b852ae50355749b47d92452ced6f166d32ee9df5da714356a2", size = 4011087, upload-time = "2026-09-05T19:25:26.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/9d/07e939e83c4b223a0a5941664c2447dcd24c0d3c11c78721889d02d16443/pydantic_monty_client-0.0.23-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2d48db758abafcf45cdbfd710f998a6b0af991267587831498739ac11fd7e3b5", size = 3899694, upload-time = "2026-09-05T19:25:27.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/b7/fdbf223f919b2b8ffdd2df79912b3eec1db274656486534b1ffd84b8dbc1/pydantic_monty_client-0.0.23-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7fedc8d482e9338e8e97d2d433cf82b10f671d98416b427a52ab034978731e3d", size = 4204183, upload-time = "2026-09-05T19:25:29.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/6d/b231969ef89209c02b172fddb6182f328b56f89b6c494b238cd923c50766/pydantic_monty_client-0.0.23-cp314-cp314-win32.whl", hash = "sha256:3bc5e57df0e44057b97614149ea749438f3e3cb48c93c9a06f0ffc753824a28d", size = 3289691, upload-time = "2026-09-05T19:25:30.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/5f/5e681044f43f6e3fd42dffc84548d86c0a15b06486f29194eca54caa1b8e/pydantic_monty_client-0.0.23-cp314-cp314-win_amd64.whl", hash = "sha256:e2b664edc793fda985f7fb6a03fddfe536fd6de4b7dba0765282938a883f4c51", size = 3929684, upload-time = "2026-09-05T19:25:32.378Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-monty-runtime"
|
||||
version = "0.0.19"
|
||||
version = "0.0.23"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e2/29/e44460fd934584ddecc6b8a8acddbc0605e86ea9d027910acdbf526c8f14/pydantic_monty_runtime-0.0.19.tar.gz", hash = "sha256:717e11349d7234575750cec881ac390961de02a40bd09f875dc5e097705b896b", size = 1322628, upload-time = "2026-07-24T10:00:14.955Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/1d/c1139f1f82460046d505d7a6871d4d22f9e36cdab967807487f6a70cb51f/pydantic_monty_runtime-0.0.23.tar.gz", hash = "sha256:d181f557cd3d19ee826459dea234a2b440e2b6de093e66384629843959146071", size = 1727575, upload-time = "2026-09-05T19:26:05.353Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/11/a6f4e12982b2232b9036db334fbcfecbacf46b9acaf311f9c3110e431c53/pydantic_monty_runtime-0.0.19-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:be34905548e31237fc683f5a34986489c127727e8f65481e8c87c4ad0b3a4dc2", size = 9449108, upload-time = "2026-07-24T09:58:44.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/a0/1e720b1bba457c6a1ab4fca20c571ae058238c7df3e1892b5efebad05a8b/pydantic_monty_runtime-0.0.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0f402f20672c1e25d4915de7e97023519461b9ffbedacd08c427d40daa77bdd6", size = 9735875, upload-time = "2026-07-24T09:58:46.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/97/4439b312d9463881630dd9d998d2c8fe2b8ef457b451202c71816e25688c/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9ec0d25dfbe65b9a5dfe77ff86353e95a86774d786a1d496206163f828f072fd", size = 9171496, upload-time = "2026-07-24T09:58:49.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/67/fa7b99afe1917b89eec3b4b6375f468c76eeea8227dd48361b7f2db90d97/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_armv7l.whl", hash = "sha256:7ecc2f8eeabf6483908db4cc69b7d4c156a95675dcbdda2a7540a22ed904bfb2", size = 9565495, upload-time = "2026-07-24T09:58:51.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/a6/83a9dceb8d9f5dffd9a082b60590bb61b0ac48dca19aa02847ebbab1ad46/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:fc1e508b9dc2cab64e27004d0f4ce44c7e1d255e85bafba390884fa07d696319", size = 10199598, upload-time = "2026-07-24T09:58:54.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/5e/bafd9dfa9a9a0d26b9882053aaea2280d791225c1e3b33b4b48f23fd5f92/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:9e3f6e366f62e54d7bd0fb995f007f42d23c26eee560d57c04d75d5adde3b45c", size = 10355698, upload-time = "2026-07-24T09:58:56.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/00/ef55cdc9f5ade20475622bb8dba617efce00ef9da2b94b36a76172edadce/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:2ddf4f2d1d063f330bb54b2dc00f6d5bcfbdd3defdc5988b856d350f62160e26", size = 10197859, upload-time = "2026-07-24T09:58:59.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/67/d1c359658458cf97296fda4359bfc71de8c9f968fb3db68eb7ce00136d43/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ce2c9550c30a5c94b63dfe578243d0823511feeecb020723f5140f9737505410", size = 10661715, upload-time = "2026-07-24T09:59:01.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/1f/9841d93f956bfbd0bbbac75edf42bb3b13b5bad7d25b09d9a221b5e54d71/pydantic_monty_runtime-0.0.19-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:05563e178b6be783de088abe4a25cef9cfbc04811f7c1e1873860252e711edac", size = 9143212, upload-time = "2026-07-24T09:59:04.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ce/0cd3643cc5051456b7baad6f16d85fb1d05daefd0556e4c82ea8f22cc9a2/pydantic_monty_runtime-0.0.19-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:de74aa4df47147d104bccdac4b39c1f3fd543091be3fd0156f77eeea3e483bc3", size = 9731590, upload-time = "2026-07-24T09:59:06.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/5a/69f4eabec2538df364242395ab3ef77b30a124a0e4b461231589651a1e97/pydantic_monty_runtime-0.0.19-cp312-cp312-win32.whl", hash = "sha256:5208056d9e23d951768ba4b94df3caf7fd84bfe951f68ec4a1803eb03377bbeb", size = 9227834, upload-time = "2026-07-24T09:59:08.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/4b/221a21f477aef0c488cbe1467111b0988658bc4a42cfc6b404201bc432af/pydantic_monty_runtime-0.0.19-cp312-cp312-win_amd64.whl", hash = "sha256:e4bba0c6024a3a8bd8c8a8cba25233a19cf686218e97afb4c059aa0c625a4b8b", size = 10941520, upload-time = "2026-07-24T09:59:10.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/a8/1ba497c35eca33273f2144b8e78d94832cc33aec5f69d8bef56968a61933/pydantic_monty_runtime-0.0.19-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:84dd041652581335503af7c60ee7362a462d946a62e8c4a44a939984034d0d25", size = 9449108, upload-time = "2026-07-24T09:59:13.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/6e/08c05c9729a35d6c9831bfd57d535bd1257f601d15b62936c27f1c0cfb47/pydantic_monty_runtime-0.0.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:263ab33dce2008ca6c83b4013b0280a98a1991d4071c55413d00b539940fe8aa", size = 9735874, upload-time = "2026-07-24T09:59:15.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/64/63fbdb4c069ceb2af6261fe5923864b5be309799087f16a5dccc96141c12/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:64f56d48097d8a158125534b20e8b22003c5e727082a303907da8e61aae0c7e3", size = 9171498, upload-time = "2026-07-24T09:59:18.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/cf/82390fe7f0e1100662e6cac7a1c0de716ea4bf851a53aa2b0ef324fc4e3e/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_armv7l.whl", hash = "sha256:a4d80fb4598524cd5039a596f8e6900adb2a5a2da66d012a3734a2b441ad06c3", size = 9565494, upload-time = "2026-07-24T09:59:21.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/37/caa8bf32ba0c0e8ce31ef2773b1a1f60d688e0237cd396e48bbef9f7161f/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:1c5cd0b140c765772e0606a7047fbf95cc49d62d0d8b79b4f520dae0e38b3ba7", size = 10199597, upload-time = "2026-07-24T09:59:23.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/25/ae9bb52518b2ce8fe7509d6cad4f4916b4458b748fecb180bef2d78165e9/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:7d80975c6e792088285f49a91e26de483ef95e5bffc4939b02d4c5ea1059749b", size = 10355699, upload-time = "2026-07-24T09:59:26.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/38/0875b1239b8ea57e4ea6de421cd240fc5a88c02e381f88d858f00439b1e9/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:f826cb911f78fe4fddf09fa3f38518484041908d08de927fa7dd134c002a2c77", size = 10197858, upload-time = "2026-07-24T09:59:28.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/d4/9365473fe31e53a6dc4d6fea406d5d8f3f03a9b7d84d1f29a9e6d664c862/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:fdf56c6cd8c6163737d1ce284f9acb00feff77b7e952f041dc281b94336c4fc9", size = 10661715, upload-time = "2026-07-24T09:59:31.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/f9/8d85b8f77d4006a8d80517a0781c49e6ca026f68747f801b63298e64197e/pydantic_monty_runtime-0.0.19-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a675f165773af0a2e0209dd96014aa9e613115cdc6dad297a7bb96cf87f83315", size = 9143210, upload-time = "2026-07-24T09:59:33.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/53/ded73742ee9fa2160c711141c28ea2ee083f073019e89724049c5e67cd84/pydantic_monty_runtime-0.0.19-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9e42ab0287c0497d9d5529e8a53dd2f63f9d1f92f6592170baab894ea9956da6", size = 9731590, upload-time = "2026-07-24T09:59:36.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/06/67be8320dc592b8c4caa8c4c1544c8ddf2760029d46843d078aa5a4cdb14/pydantic_monty_runtime-0.0.19-cp313-cp313-win32.whl", hash = "sha256:1f5ff1b9585e648304096705045fb6bd90d43b561568b0d373265e8d201b1234", size = 9227833, upload-time = "2026-07-24T09:59:39.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/f4/bab34897974d83640f8773f03d2001142bc13e80e517bfce8c8c4a57157e/pydantic_monty_runtime-0.0.19-cp313-cp313-win_amd64.whl", hash = "sha256:7181a2153ff257fe34109685148167b6e8219d4acfe8f346115b58152fd26aa8", size = 10941519, upload-time = "2026-07-24T09:59:41.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/8d/f46ac4778b2ac64183607bc63ecc783f16ca9981de30eb8ec9aa5e7132cd/pydantic_monty_runtime-0.0.19-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4cc92139b476469c0d7e5caa147d38c2929de39bf1724cb22bbab80297823963", size = 9449107, upload-time = "2026-07-24T09:59:44.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/14/34a7bb4630d1bac0d055049568ecc888a87954c176428bde5764a7ff6ed7/pydantic_monty_runtime-0.0.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a97e00bd46305f34a85f2e2e3ac4c92dbd3340e7af694aafb192ff58c4fb40e", size = 9735874, upload-time = "2026-07-24T09:59:46.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b7/977de0b258c0858f52b23bd32afbfdf8a8c4614daff5d0b7d5c86332ce6e/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:46ad28b1b3c41113c9e89373da18bcc883a24da371d4eeb9b32d3d194286f1a5", size = 9171497, upload-time = "2026-07-24T09:59:48.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/39/4374200ee4b938fc8b5026056f13bb677e15796bca1323a16210738431ad/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_armv7l.whl", hash = "sha256:dc61844187a32c2f9c2b69846c5d55679eb838b4f7e495b4d03509f89fbf41f9", size = 9565495, upload-time = "2026-07-24T09:59:51.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/55/c4ee4b0a10610359e09cad9d327db19095914a3bf427fb3d8164ec2bcae0/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:b23b52a79ee6be0a943e4b8e5d996e6c489a37b23a337838164f22c9e8ed11a7", size = 10199598, upload-time = "2026-07-24T09:59:53.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/62/cc9df084e9f930bbb2873b6dd832b377f76071aec309b1545589d818fd90/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:484496817f5f238c42aaea8a1945b545a17d8ef2a21e9e81799d55e481d25485", size = 10355699, upload-time = "2026-07-24T09:59:56.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/cc/bb13e6f655fcaee340032ad6a3cd1524957d0fa471ddc7e27bdb1f4c240b/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:c3e7cbad58ae9bd3402581faa7d54d719f9c140dc1888f5c9439d45bff528ea1", size = 10197859, upload-time = "2026-07-24T09:59:58.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/c3/c532715987383668ee835337e1485f51585bc8bf189f033370a05eff17f1/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5534067dc7ffdae809293da95d0e95b6d8481f4c88aff59385e19f466ba3c0f0", size = 10661715, upload-time = "2026-07-24T10:00:01.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/6c/9a0ab28a061efc184398a0b4db34e459572bb2316cf766f0d6ce65b475e3/pydantic_monty_runtime-0.0.19-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c1ae32b06a4456ab223bafa54d29a349066d625d09063c28ba14ee9019f9b7c2", size = 9143211, upload-time = "2026-07-24T10:00:03.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/5f/af5b3e6395834572975d98f4d1a00a57ee8029bf68ca5732347550f32b35/pydantic_monty_runtime-0.0.19-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:497cf8c3f30992f9aafc8707084eaffe2391b7b5dec067d04d5715f9a562c56b", size = 9731591, upload-time = "2026-07-24T10:00:06.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/98/96797fd269342cfdb49f03c22fd9a05ef91f711089081c4b3861b9c520e2/pydantic_monty_runtime-0.0.19-cp314-cp314-win32.whl", hash = "sha256:942feb948df8edb61ae7ba6ae77dc655e6985be06d72eef886562fe573ba3086", size = 9227832, upload-time = "2026-07-24T10:00:08.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/fc/02d15281c8e00b48df9af8f75a4fe06f3f8f33ef6a910507a45a19f2b61b/pydantic_monty_runtime-0.0.19-cp314-cp314-win_amd64.whl", hash = "sha256:91d93339c70483ed9256b3b15e3375f6597ae65be280f9b89ba9ca0355f95f54", size = 10941519, upload-time = "2026-07-24T10:00:11.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/97/d1575be31396cf662ea8419d427c58279378a2e08b674d436ed4990751ea/pydantic_monty_runtime-0.0.23-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c8968491ae44648c11cf075c0f5d798d4aa7f7e4a0d0754add0b74d725c075c", size = 9828049, upload-time = "2026-09-05T19:25:34.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/09/01ae8472d860223618cd61a4656d8ff44519e1bd9e320ee8698dbb206aca/pydantic_monty_runtime-0.0.23-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c60e44cbef9cfdc9bd5e53631650490e66890f57e8c6bea5611dae502d8374d4", size = 11548563, upload-time = "2026-09-05T19:25:37.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/30/ebf796ec2236b15cb9f5541e3a940536f37de41bd2561989985aa92fb396/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4bc24603678107b7f5b2b24a5946ca1e103379b2289589212c86959993a052b4", size = 11935977, upload-time = "2026-09-05T19:25:39.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/40/8ed68e144f80c9d14e0532d631ffd78ee6a0ae8d7d3272ac037d5d703266/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_armv7l.whl", hash = "sha256:7c68422e06c5aae8b52f9835ee949256db0f261301515e4443d6647f249cf789", size = 10180097, upload-time = "2026-09-05T19:25:42.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/f3/ea3632cebb754b81fa63c01b84c89299bba936c670fa519f3ff363166bcc/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_i686.whl", hash = "sha256:b8a049a554bff6ba7e553c1ae4b6290e554b6a1695bc1d47b1b03db485f7fb91", size = 10515148, upload-time = "2026-09-05T19:25:44.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/a9/6a43d9d9f01f441ecd6ec6c31d9b8d45b143a37b9368f582b27378e50ec7/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_ppc64le.whl", hash = "sha256:9cea174ed1a56888bb58975feff34b0475504ab3831b3b1dc3189397d5bcccc3", size = 10746650, upload-time = "2026-09-05T19:25:46.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/d0/dc2c2e066ce4f619f34a98b44c9c84fb398095421808c9dd6ac12463aea2/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_s390x.whl", hash = "sha256:28bb59cde9de0cec0d3d5d72029851757249c682a6747251f61e8cdece5995f7", size = 11092334, upload-time = "2026-09-05T19:25:49.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/82/6af8a19dd357d63e783e9647ef81d47db2f48c13e96558d25484cd0dec91/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:721d2d1455482c929c934412e61ecf56673b88a2c0c049e041295cf543ff1412", size = 12371608, upload-time = "2026-09-05T19:25:51.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/53/2939bce8c8a688771777ee2301851a712744d809d752c1c8dcb5d6a52574/pydantic_monty_runtime-0.0.23-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:f0183e589ba18de4fac8a675fe0f93049baa293d5409eb8d39c639dc24af943f", size = 9593312, upload-time = "2026-09-05T19:25:53.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/f9/180d9acbea18d8050c190280ef591cb52d05e4d0e6d026cc6b7b2613510c/pydantic_monty_runtime-0.0.23-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:cbf56c19712c12072f6607d8e2323941441c9343734dcb07d2d2b4ce2dd98bf3", size = 10133520, upload-time = "2026-09-05T19:25:56.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/00/dcd69bd9fcc57c7a6ecfcd3b2199f4598853edb14d504f481dcda41b17cd/pydantic_monty_runtime-0.0.23-py3-none-win32.whl", hash = "sha256:854f58a472397f34588a22eb813f2acd65efc0bcb966ef1fe173c32366209d15", size = 9639882, upload-time = "2026-09-05T19:25:58.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/c0/3f7c604ac93ec47a36a3733d6e7717b7096f0989f669d4133bd3e85f3375/pydantic_monty_runtime-0.0.23-py3-none-win_amd64.whl", hash = "sha256:742375f494e298a4f96933ac3694a0fe34c9cddd9b666669bc193f348bbed0b8", size = 10404629, upload-time = "2026-09-05T19:26:01.143Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5543,11 +5539,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "uncalled-for"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/7c/b5b7d8136f872e3f13b0584e576886de0489d7213a12de6bebf29ff6ebfc/uncalled_for-0.2.0.tar.gz", hash = "sha256:b4f8fdbcec328c5a113807d653e041c5094473dd4afa7c34599ace69ccb7e69f", size = 49488, upload-time = "2026-02-27T17:40:58.137Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/5a/92ce0b3ea5481915f55da994c2c2c5f7a3c09949afde196ee89f8ab961aa/uncalled_for-0.4.0.tar.gz", hash = "sha256:335b95bd2422332ec210d518f314a16e4c640921c39fc8bf2ad095bd3538f4af", size = 56979, upload-time = "2026-08-10T14:51:46.247Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl", hash = "sha256:2c0bd338faff5f930918f79e7eb9ff48290df2cb05fcc0b40a7f334e55d4d85f", size = 11351, upload-time = "2026-02-27T17:40:56.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/40/97cec87c077eb3291fc7905e6633e08b7ca593c57d30238444bcb6bb3d53/uncalled_for-0.4.0-py3-none-any.whl", hash = "sha256:16c4bb3337532e4bd5569adc192285976f3ad5305402256d34c67a12b5c968bd", size = 15502, upload-time = "2026-08-10T14:51:45.068Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Reference in a new issue