Compare commits
No commits in common. "main" and "0.82.1" have entirely different histories.
54 changed files with 1118 additions and 2706 deletions
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
{
|
|
||||||
"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."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
67
CHANGELOG.md
67
CHANGELOG.md
|
|
@ -2,73 +2,6 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
|
||||||
`ollama:ministral-3`). Run `ollama pull qwen3.8`.
|
|
||||||
- `qa.model.vision` defaults to `true`, matching `qwen3.8`. Set it `false` when
|
|
||||||
pointing `qa.model` at a text-only model.
|
|
||||||
- `enable_thinking` on `provider: ollama` maps to `reasoning_effort` for every
|
|
||||||
model, not only `gpt-oss`: `false` sends `none`, `true` sends `high`.
|
|
||||||
`gpt-oss` keeps `low` for `false`.
|
|
||||||
- `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
|
## [0.82.1] - 2026-09-03
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
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
|
- **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
|
- **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)
|
- **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` and the chat TUI
|
- **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
|
||||||
- **Reranking** — local cross-encoders, Cohere, Zero Entropy, or vLLM
|
- **Reranking** — local cross-encoders, Cohere, Zero Entropy, or vLLM
|
||||||
- **Analysis capability** — Complex analytical tasks via sandboxed Python code execution (aggregation, computation, multi-document analysis)
|
- **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
|
- **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,26 +110,12 @@ For direct agent composition, see the [capabilities documentation](https://ggoza
|
||||||
|
|
||||||
## MCP Server
|
## MCP Server
|
||||||
|
|
||||||
Use with AI assistants like Claude Code, Codex, and Claude Desktop:
|
Use with AI assistants like Claude Desktop:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
haiku-rag mcp --stdio
|
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:
|
Add to your Claude Desktop configuration:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -143,7 +129,7 @@ Add to your Claude Desktop configuration:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Provides search, document reading, and analysis tools directly in your AI assistant.
|
Provides tools for document management, search, QA, and analysis directly in your AI assistant.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,4 +40,4 @@ EXPOSE 8001 8765
|
||||||
|
|
||||||
# Default command: read-only MCP server. The companion ingester service is
|
# Default command: read-only MCP server. The companion ingester service is
|
||||||
# launched via docker-compose against the same image.
|
# launched via docker-compose against the same image.
|
||||||
CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "mcp", "--port", "8001"]
|
CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "--read-only", "mcp", "--port", "8001"]
|
||||||
|
|
|
||||||
|
|
@ -39,4 +39,4 @@ EXPOSE 8001 8765
|
||||||
|
|
||||||
# Default command: read-only MCP server. The companion ingester service is
|
# Default command: read-only MCP server. The companion ingester service is
|
||||||
# launched via docker-compose against the same image.
|
# launched via docker-compose against the same image.
|
||||||
CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "mcp", "--port", "8001"]
|
CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "--read-only", "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_execute_code(code)` | Run Python against the virtual document filesystem. |
|
||||||
| `analysis_cite(chunk_ids)` | Register retrieved or filesystem-derived chunk IDs. |
|
| `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`, `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).
|
The sandbox exposes documents under `/documents/{document_id}/` with `metadata.json`, `content.txt`, `items.jsonl`, and `toc.json`.
|
||||||
|
|
||||||
## Compose an agent
|
## Compose an agent
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ The `haiku-rag` CLI provides complete document management functionality.
|
||||||
haiku-rag add -h
|
haiku-rag add -h
|
||||||
```
|
```
|
||||||
|
|
||||||
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).
|
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).
|
||||||
|
|
||||||
## Document Management
|
## Document Management
|
||||||
|
|
||||||
|
|
@ -477,6 +477,9 @@ haiku-rag mcp --port 9000
|
||||||
|
|
||||||
# Bind to all interfaces (containers, trusted LAN)
|
# Bind to all interfaces (containers, trusted LAN)
|
||||||
haiku-rag mcp --host 0.0.0.0
|
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
|
See [MCP](mcp.md) for details. For continuous document ingestion
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ embeddings:
|
||||||
qa:
|
qa:
|
||||||
model:
|
model:
|
||||||
provider: ollama
|
provider: ollama
|
||||||
name: qwen3.8
|
name: gpt-oss
|
||||||
enable_thinking: true
|
enable_thinking: true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -105,7 +105,7 @@ reranking:
|
||||||
qa:
|
qa:
|
||||||
model:
|
model:
|
||||||
provider: ollama
|
provider: ollama
|
||||||
name: qwen3.8
|
name: gpt-oss
|
||||||
enable_thinking: true
|
enable_thinking: true
|
||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
max_searches: 5
|
max_searches: 5
|
||||||
|
|
@ -135,7 +135,7 @@ processing:
|
||||||
auto_title: false # Auto-generate titles on ingestion
|
auto_title: false # Auto-generate titles on ingestion
|
||||||
title_model:
|
title_model:
|
||||||
provider: ollama
|
provider: ollama
|
||||||
name: qwen3.8
|
name: gpt-oss
|
||||||
enable_thinking: false
|
enable_thinking: false
|
||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
max_tokens: 100
|
max_tokens: 100
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ processing:
|
||||||
auto_title: false # Auto-generate titles on ingestion
|
auto_title: false # Auto-generate titles on ingestion
|
||||||
title_model: # LLM for title generation (fallback)
|
title_model: # LLM for title generation (fallback)
|
||||||
provider: ollama
|
provider: ollama
|
||||||
name: qwen3.8
|
name: gpt-oss
|
||||||
enable_thinking: false
|
enable_thinking: false
|
||||||
|
|
||||||
# Conversion options (works with both local and remote converters)
|
# Conversion options (works with both local and remote converters)
|
||||||
|
|
@ -54,7 +54,7 @@ processing:
|
||||||
picture_description:
|
picture_description:
|
||||||
model:
|
model:
|
||||||
provider: ollama
|
provider: ollama
|
||||||
name: qwen3.8
|
name: ministral-3
|
||||||
pictures: image # none | description | image
|
pictures: image # none | description | image
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -270,7 +270,7 @@ processing:
|
||||||
picture_description: # only consulted when pictures == "description"
|
picture_description: # only consulted when pictures == "description"
|
||||||
model:
|
model:
|
||||||
provider: ollama # any OpenAI-compatible /v1/chat/completions provider
|
provider: ollama # any OpenAI-compatible /v1/chat/completions provider
|
||||||
name: qwen3.8
|
name: ministral-3
|
||||||
timeout: 90
|
timeout: 90
|
||||||
max_tokens: 200
|
max_tokens: 200
|
||||||
```
|
```
|
||||||
|
|
@ -294,7 +294,7 @@ Three independent settings drive ingest, retrieval, and QA:
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `processing.pictures` | Generate and/or describe pictures at ingest? | `none` / `description` / `image` (default) |
|
| `processing.pictures` | Generate and/or describe pictures at ingest? | `none` / `description` / `image` (default) |
|
||||||
| `embeddings.model.multimodal` | Can the embedder index image content? | `false` (default, text-only) / `true` (supported on `vllm`, `voyageai`, `cohere`) |
|
| `embeddings.model.multimodal` | Can the embedder index image content? | `false` (default, text-only) / `true` (supported on `vllm`, `voyageai`, `cohere`) |
|
||||||
| `qa.model.vision` | Can the QA model interpret images? | `false` / `true` (default) |
|
| `qa.model.vision` | Can the QA model interpret images? | `false` (default) / `true` |
|
||||||
|
|
||||||
The Embedder column below is driven by `embeddings.model.multimodal`, not the provider name — a vision-capable model under a text-only configuration still indexes no images, and an image-only document then produces zero chunks. See [Multimodal embedders](providers.md#multimodal-embedders).
|
The Embedder column below is driven by `embeddings.model.multimodal`, not the provider name — a vision-capable model under a text-only configuration still indexes no images, and an image-only document then produces zero chunks. See [Multimodal embedders](providers.md#multimodal-embedders).
|
||||||
|
|
||||||
|
|
@ -313,7 +313,7 @@ The Embedder column below is driven by `embeddings.model.multimodal`, not the pr
|
||||||
- `qa.model.vision: false` — text chunks only (descriptions, when present, answer figure questions in prose).
|
- `qa.model.vision: false` — text chunks only (descriptions, when present, answer figure questions in prose).
|
||||||
- `qa.model.vision: true` — text chunks + raw picture bytes via `BinaryContent`. The model reads figures directly. Requires `pictures != none` so the bytes exist.
|
- `qa.model.vision: true` — text chunks + raw picture bytes via `BinaryContent`. The model reads figures directly. Requires `pictures != none` so the bytes exist.
|
||||||
|
|
||||||
`qa.model.vision` is independent of ingestion. Flipping it never requires reingesting. It declares what the model can read: the default `qwen3.8` is vision-capable, so the default is `true`. Set it `false` when pointing `qa.model` at a text-only model, where `true` causes silent acceptance and confabulation on Ollama and a 400 on OpenAI.
|
`qa.model.vision` is independent of ingestion. Flipping it never requires reingesting. Setting `vision: true` against a text-only model causes silent acceptance and confabulation on Ollama and a 400 on OpenAI. Default `false` is the safe choice.
|
||||||
|
|
||||||
**Recommended combinations:**
|
**Recommended combinations:**
|
||||||
|
|
||||||
|
|
@ -368,7 +368,7 @@ processing:
|
||||||
auto_title: true
|
auto_title: true
|
||||||
title_model:
|
title_model:
|
||||||
provider: ollama
|
provider: ollama
|
||||||
name: qwen3.8
|
name: gpt-oss
|
||||||
enable_thinking: false
|
enable_thinking: false
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ Configure model behavior for the `qa` and `analysis` capabilities. These setting
|
||||||
qa:
|
qa:
|
||||||
model:
|
model:
|
||||||
provider: ollama
|
provider: ollama
|
||||||
name: qwen3.8
|
name: gpt-oss
|
||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
max_tokens: 500
|
max_tokens: 500
|
||||||
```
|
```
|
||||||
|
|
@ -79,7 +79,7 @@ See the [Pydantic AI thinking documentation](https://ai.pydantic.dev/thinking/)
|
||||||
- **Google**: Gemini models with thinking support
|
- **Google**: Gemini models with thinking support
|
||||||
- **Groq**: Models with reasoning capabilities
|
- **Groq**: Models with reasoning capabilities
|
||||||
- **Bedrock**: Claude, Qwen, and `gpt-oss` models. Bedrock Converse does not serve the proprietary OpenAI models, so configuring one raises an error. Reach those through `provider: bedrock-mantle`.
|
- **Bedrock**: Claude, Qwen, and `gpt-oss` models. Bedrock Converse does not serve the proprietary OpenAI models, so configuring one raises an error. Reach those through `provider: bedrock-mantle`.
|
||||||
- **Ollama**: Any model with a thinking capability. `enable_thinking` maps to `reasoning_effort`: `false` sends `none` (`low` for `gpt-oss`, whose template has no `none` level), `true` sends `high`.
|
- **Ollama**: Models supporting reasoning (gpt-oss, etc.)
|
||||||
- **vLLM**: Models with a pydantic-ai reasoning profile (gpt-oss). Qwen3, Gemma, and similar templates ignore the OpenAI `reasoning_effort` that `enable_thinking` translates to — use [`extra_body`](#raw-provider-pass-through) to drive them.
|
- **vLLM**: Models with a pydantic-ai reasoning profile (gpt-oss). Qwen3, Gemma, and similar templates ignore the OpenAI `reasoning_effort` that `enable_thinking` translates to — use [`extra_body`](#raw-provider-pass-through) to drive them.
|
||||||
- **LM Studio**: Models supporting reasoning (gpt-oss, etc.)
|
- **LM Studio**: Models supporting reasoning (gpt-oss, etc.)
|
||||||
|
|
||||||
|
|
@ -311,7 +311,7 @@ Configure which LLM provider to use for question answering. Any provider and mod
|
||||||
qa:
|
qa:
|
||||||
model:
|
model:
|
||||||
provider: ollama
|
provider: ollama
|
||||||
name: qwen3.8
|
name: gpt-oss
|
||||||
```
|
```
|
||||||
|
|
||||||
The Ollama base URL can be configured via the `OLLAMA_BASE_URL` environment variable, config file, or defaults to `http://localhost:11434`:
|
The Ollama base URL can be configured via the `OLLAMA_BASE_URL` environment variable, config file, or defaults to `http://localhost:11434`:
|
||||||
|
|
|
||||||
|
|
@ -20,16 +20,16 @@ Context expansion is automatic and section-aware. For structured documents (with
|
||||||
|
|
||||||
## Question Answering Configuration
|
## Question Answering Configuration
|
||||||
|
|
||||||
Configure the RAG capability (used by `client.ask` and `haiku-rag ask`):
|
Configure the RAG capability (used by `client.ask`, `haiku-rag ask`, and the MCP `ask_question` tool):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
qa:
|
qa:
|
||||||
model:
|
model:
|
||||||
provider: ollama
|
provider: ollama
|
||||||
name: qwen3.8
|
name: gpt-oss
|
||||||
enable_thinking: true
|
enable_thinking: true
|
||||||
temperature: 0.3 # Default: 0.3
|
temperature: 0.3 # Default: 0.3
|
||||||
vision: true # Set false for text-only models
|
vision: false # Set true for vision-capable models
|
||||||
max_searches: 5 # Maximum search units per question
|
max_searches: 5 # Maximum search units per question
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -50,13 +50,13 @@ analysis:
|
||||||
provider: anthropic
|
provider: anthropic
|
||||||
name: claude-sonnet-4-20250514
|
name: claude-sonnet-4-20250514
|
||||||
temperature: 0.0 # Default: 0.0 (deterministic for code generation)
|
temperature: 0.0 # Default: 0.0 (deterministic for code generation)
|
||||||
code_timeout: 60.0 # Per call: compute stops, no read or search starts past it
|
code_timeout: 60.0 # Max seconds a call may spend reading documents
|
||||||
max_output_chars: 50000 # Truncate output after this many chars
|
max_output_chars: 50000 # Truncate output after this many chars
|
||||||
max_executions: 15 # Max execute_code calls per question
|
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`.
|
- **model**: LLM configuration (see [Providers](providers.md#model-settings)). When unset, falls back to `qa.model`.
|
||||||
- **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.
|
- **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.
|
||||||
- **max_output_chars**: Truncate code output after this many characters (default: 50000)
|
- **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)
|
- **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":
|
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.
|
- **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 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 --read-only 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.
|
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:
|
Commands use database sets as follows:
|
||||||
|
|
||||||
- **Set-capable**: `search`, `ask`, `analyze`, `chat`, and `mcp` use the full configured set, or the single database selected by `--db-name`.
|
- **Set-capable**: `search`, `ask`, `analyze`, and `chat` 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.
|
- **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`, and `visualize` — 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`, `visualize`, and `mcp` — works on one database, selected with the global `--db-name` option.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
haiku-rag search "query" # every configured database
|
haiku-rag search "query" # every configured database
|
||||||
|
|
|
||||||
180
docs/mcp.md
180
docs/mcp.md
|
|
@ -19,71 +19,15 @@ haiku-rag mcp --host 0.0.0.0 --port 8001
|
||||||
# stdio transport (for Claude Desktop)
|
# stdio transport (for Claude Desktop)
|
||||||
haiku-rag mcp --stdio
|
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
|
`--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 —
|
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.
|
e.g. inside a Docker container with port mapping, or on a trusted LAN.
|
||||||
|
|
||||||
The server opens the database read-only. Ingestion goes through the CLI
|
**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.
|
||||||
(`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
|
## Claude Desktop Integration
|
||||||
|
|
||||||
|
|
@ -113,95 +57,63 @@ With a custom database path:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
After restarting Claude Desktop, you can ask Claude to search your documents or answer questions using your knowledge base.
|
After restarting Claude Desktop, you can ask Claude to search your documents, add new content, or answer questions using your knowledge base.
|
||||||
|
|
||||||
## Tools
|
## Available Tools
|
||||||
|
|
||||||
Every tool is read-only and says so in its annotations. Each parameter carries
|
### Document Management
|
||||||
a description in the tool schema, so the listing below names them without
|
|
||||||
repeating it.
|
|
||||||
|
|
||||||
| Tool | Registered | Parameters |
|
- **`add_document_from_file`** - Add documents from local file paths
|
||||||
|---|---|---|
|
- `file_path` (required): Path to the file
|
||||||
| `search_documents` | always | `query`, `limit`, `include_images`, `filter`, `sources` |
|
- `metadata` (optional): Key-value metadata
|
||||||
| `search_documents_by_image` | multimodal embedder only | `image_base64`, `limit`, `include_images`, `filter`, `sources` |
|
- `title` (optional): Human-readable title
|
||||||
| `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` |
|
|
||||||
|
|
||||||
`search_documents` runs hybrid search, vector and full-text. Its text content
|
- **`add_document_from_url`** - Add documents from URLs
|
||||||
is the rendering the in-process agents read: results best first, each with its
|
- `url` (required): URL to fetch
|
||||||
rank, `Document ID`, `Collection` when the server covers several, the document
|
- `metadata` (optional): Key-value metadata
|
||||||
title, section headings, the matched chunk's metadata when it has any, and the
|
- `title` (optional): Human-readable title
|
||||||
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.
|
|
||||||
|
|
||||||
`get_document` returns a document whole, in reading order. For a long one,
|
- **`add_document_from_text`** - Add documents from raw text content
|
||||||
`get_document_outline` returns the heading tree with page numbers and
|
- `content` (required): Text content
|
||||||
`get_document_section` the text of one section, subsections included; a
|
- `uri` (optional): URI identifier
|
||||||
node's `id` in the outline is the `section_id`. A document without headings
|
- `metadata` (optional): Key-value metadata
|
||||||
has an empty outline. `list_documents` returns titles, URIs and metadata,
|
- `title` (optional): Human-readable title
|
||||||
which is how a client learns what a filter can match.
|
|
||||||
|
|
||||||
### Code
|
- **`get_document`** - Retrieve a document by ID
|
||||||
|
- `document_id` (required): The document ID
|
||||||
|
|
||||||
`execute_code` runs a Python program in the sandbox of the
|
- **`list_documents`** - List documents with pagination and filtering
|
||||||
[analysis capability](capabilities/analysis.md), over the documents `filter`
|
- `limit` (optional): Maximum number to return
|
||||||
and `sources` select, and returns what it printed. The program reads
|
- `offset` (optional): Number to skip
|
||||||
`/documents/{document_id}/` (`metadata.json`, `content.txt`, `items.jsonl`,
|
- `filter` (optional): SQL WHERE clause for filtering
|
||||||
`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.
|
|
||||||
|
|
||||||
The interpreter is [Monty](https://github.com/pydantic/monty), a Python subset.
|
- **`delete_document`** - Delete a document by ID
|
||||||
Useful modules include `json`, `re`, `math`, `pathlib`, `datetime`,
|
- `document_id` (required): The document ID
|
||||||
`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.
|
|
||||||
|
|
||||||
### Filters
|
### Search
|
||||||
|
|
||||||
`filter` is a SQL WHERE clause over the document columns `id`, `uri`, `title`,
|
- **`search_documents`** - Search using hybrid search (vector + full-text)
|
||||||
`metadata`, `created_at`, `updated_at`. `metadata` is a JSON string, so match
|
- `query` (required): Search query
|
||||||
its keys with LIKE:
|
- `limit` (optional): Maximum results (uses config default if not specified)
|
||||||
|
- `include_images` (optional, default `true`): Attach base64-encoded picture bytes to picture-labeled results
|
||||||
|
|
||||||
```sql
|
- **`search_documents_by_image`** - Search using an image as the query (registered only when the configured embedder supports images)
|
||||||
metadata LIKE '%"author": "Smith"%'
|
- `image_base64` (required): Base64-encoded image (PNG/JPEG bytes)
|
||||||
uri LIKE '%.pdf'
|
- `limit` (optional): Maximum results
|
||||||
title = 'Q3 report'
|
- `include_images` (optional, default `true`)
|
||||||
```
|
|
||||||
|
|
||||||
### Errors
|
### Question Answering
|
||||||
|
|
||||||
A failure is an MCP error carrying its message, never an empty result: a
|
- **`ask_question`** - Ask questions about your documents
|
||||||
document or section id that matches nothing, a collection the server does not
|
- `question` (required): The question to ask
|
||||||
cover, a filter the query engine rejects, invalid base64, a program that fails
|
- `cite` (optional): Include source citations (default: false)
|
||||||
in `execute_code` with the error it hit, and anything unexpected with its own
|
- `images_base64` (optional): Base64-encoded images attached to the question (requires a vision-capable QA model)
|
||||||
message.
|
|
||||||
|
|
||||||
### Instructions
|
- **`analyze`** - Answer complex analytical questions via code execution
|
||||||
|
- `question` (required): The question to answer
|
||||||
The server publishes `instructions` describing the knowledge base: what it
|
- `filter` (optional): SQL WHERE clause to restrict document access
|
||||||
holds, when to reach for it, the collection names when it covers several, and
|
- `images_base64` (optional): Base64-encoded images attached to the question (requires a vision-capable analysis model)
|
||||||
`prompts.domain_preamble` when set. Claude Code and Codex show them to the
|
- Best for aggregation, computation, and multi-document analysis
|
||||||
model. Claude Desktop does not, so every tool description stands on its own.
|
|
||||||
|
|
||||||
## Continuous ingestion
|
## Continuous ingestion
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ You also need [Ollama](https://ollama.com/) for the default embedding and answer
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ollama pull qwen3-embedding:4b
|
ollama pull qwen3-embedding:4b
|
||||||
ollama pull qwen3.8
|
ollama pull gpt-oss
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! note "Prefer OpenAI?"
|
!!! note "Prefer OpenAI?"
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ datasets and judge:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
evaluations run hotpotqa --target rag-capability
|
evaluations run hotpotqa --target rag-capability
|
||||||
evaluations run hotpotqa --target analysis-capability --capability-model ollama:qwen3.8
|
evaluations run hotpotqa --target analysis-capability --capability-model ollama:gpt-oss
|
||||||
```
|
```
|
||||||
|
|
||||||
`--capability-model "provider:name"` overrides the capability model independently from
|
`--capability-model "provider:name"` overrides the capability model independently from
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ services:
|
||||||
"haiku-rag",
|
"haiku-rag",
|
||||||
"--config",
|
"--config",
|
||||||
"/app/haiku.rag.yaml",
|
"/app/haiku.rag.yaml",
|
||||||
|
"--read-only",
|
||||||
"mcp",
|
"mcp",
|
||||||
"--host",
|
"--host",
|
||||||
"0.0.0.0",
|
"0.0.0.0",
|
||||||
|
|
|
||||||
|
|
@ -934,7 +934,7 @@ class HaikuRAGApp:
|
||||||
# The resolved scope: a path overrides a configured URI, and a derived
|
# The resolved scope: a path overrides a configured URI, and a derived
|
||||||
# single-database configuration drops the name results and citations
|
# single-database configuration drops the name results and citations
|
||||||
# carry.
|
# carry.
|
||||||
server = _mcp_server_covering(self.scope, self.config)
|
server = _mcp_server_covering(self.scope, self.config, self.read_only)
|
||||||
try:
|
try:
|
||||||
if transport == "stdio":
|
if transport == "stdio":
|
||||||
await server.run_stdio_async()
|
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.capabilities._tools import merge_results
|
||||||
from haiku.rag.config.models import AppConfig
|
from haiku.rag.config.models import AppConfig
|
||||||
from haiku.rag.sandbox import AnalysisContext, Sandbox, recovery_hint
|
from haiku.rag.sandbox import AnalysisContext, Sandbox
|
||||||
|
|
||||||
STATE_NAMESPACE = "analysis"
|
STATE_NAMESPACE = "analysis"
|
||||||
_CAPABILITY_ID = "haiku-rag-analysis"
|
_CAPABILITY_ID = "haiku-rag-analysis"
|
||||||
|
|
@ -49,6 +49,21 @@ def multiple_collections_instructions() -> str:
|
||||||
return _multiple_collections_path.read_text().rstrip()
|
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
|
@dataclass
|
||||||
class AnalysisCapability(RAGCapabilityBase[AnalysisState]):
|
class AnalysisCapability(RAGCapabilityBase[AnalysisState]):
|
||||||
"""Deferred capability for sandboxed computation over a RAG corpus."""
|
"""Deferred capability for sandboxed computation over a RAG corpus."""
|
||||||
|
|
@ -124,7 +139,7 @@ class AnalysisCapability(RAGCapabilityBase[AnalysisState]):
|
||||||
)
|
)
|
||||||
if not result.success:
|
if not result.success:
|
||||||
raise ToolFailed(
|
raise ToolFailed(
|
||||||
f"{result.stderr}{recovery_hint(result.stderr)}"
|
f"{result.stderr}{_recovery_hint(result.stderr)}"
|
||||||
f"\n\nOutput: {result.stdout}"
|
f"\n\nOutput: {result.stdout}"
|
||||||
)
|
)
|
||||||
return result.stdout or "No output."
|
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.
|
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`):
|
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`), chunk_meta (the matched chunk's stored metadata, custom keys included)
|
- `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, metadata
|
- `await list_documents()` → list of dicts with keys: id, title, uri, created_at
|
||||||
|
|
||||||
Useful modules include `json`, `re`, `math`, `pathlib`, `datetime`, `collections`, `itertools`, `functools` and `dataclasses`. `decimal` and `statistics` do not exist.
|
Available modules: `json`, `re`, `math`, `pathlib`
|
||||||
Not supported: class inheritance and metaclasses, generators/yield, match statements, iterating a file object (`for line in f`)
|
Not supported: class inheritance and metaclasses, generators/yield, match statements, decorators, `collections`, iterating a file object (`for line in f`)
|
||||||
|
|
||||||
### analysis_search
|
### 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.
|
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,17 +39,16 @@ All documents are mounted as a virtual filesystem at `/documents/`:
|
||||||
|
|
||||||
```
|
```
|
||||||
/documents/{document_id}/
|
/documents/{document_id}/
|
||||||
metadata.json # {"id", "title", "uri", "created_at", "metadata"}
|
metadata.json # {"id", "title", "uri", "created_at"}
|
||||||
content.txt # Full document text
|
content.txt # Full document text
|
||||||
items.jsonl # Structured items (one JSON object per line)
|
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
|
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.
|
`{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
|
### 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`. 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.
|
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`.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -71,7 +70,7 @@ for line in Path(f'/documents/{doc_id}/items.jsonl').read_text().strip().split("
|
||||||
```
|
```
|
||||||
|
|
||||||
### metadata.json
|
### metadata.json
|
||||||
Document metadata: `id`, `title`, `uri`, `created_at`, and `metadata`, the keys stored with the document.
|
Document metadata: `id`, `title`, `uri`, `created_at`.
|
||||||
|
|
||||||
### content.txt
|
### content.txt
|
||||||
Full text content. Use for regex or keyword search across a whole document.
|
Full text content. Use for regex or keyword search across a whole document.
|
||||||
|
|
@ -87,9 +86,6 @@ Each row carries:
|
||||||
- `chunk_ids`: chunks that contain this item — pass to `analysis_cite()` to ground an answer that read this item directly
|
- `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
|
- `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
|
### 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.
|
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.
|
||||||
|
|
||||||
|
|
@ -116,6 +112,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
|
- 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`.
|
- 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`)
|
- 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`.
|
- 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.
|
||||||
- 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.
|
- 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.
|
- **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:
|
) -> None:
|
||||||
"""Run the MCP server."""
|
"""Run the MCP server."""
|
||||||
app = create_app(db, covers_set=True)
|
app = create_app(db)
|
||||||
|
|
||||||
transport = "stdio" if stdio else None
|
transport = "stdio" if stdio else None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ class ModelConfig(ConfigModel):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
provider: str = "ollama"
|
provider: str = "ollama"
|
||||||
name: str = "qwen3.8"
|
name: str = "gpt-oss"
|
||||||
base_url: str | None = None
|
base_url: str | None = None
|
||||||
api_key: str | None = None
|
api_key: str | None = None
|
||||||
|
|
||||||
|
|
@ -165,10 +165,9 @@ class QAConfig(ConfigModel):
|
||||||
model: ModelConfig = Field(
|
model: ModelConfig = Field(
|
||||||
default_factory=lambda: ModelConfig(
|
default_factory=lambda: ModelConfig(
|
||||||
provider="ollama",
|
provider="ollama",
|
||||||
name="qwen3.8",
|
name="gpt-oss",
|
||||||
enable_thinking=True,
|
enable_thinking=True,
|
||||||
temperature=0.3,
|
temperature=0.3,
|
||||||
vision=True,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
max_searches: int = Field(default=5, ge=0)
|
max_searches: int = Field(default=5, ge=0)
|
||||||
|
|
@ -217,8 +216,7 @@ class PictureDescriptionConfig(ConfigModel):
|
||||||
model: ModelConfig = Field(
|
model: ModelConfig = Field(
|
||||||
default_factory=lambda: ModelConfig(
|
default_factory=lambda: ModelConfig(
|
||||||
provider="ollama",
|
provider="ollama",
|
||||||
name="qwen3.8",
|
name="ministral-3",
|
||||||
enable_thinking=False,
|
|
||||||
temperature=0.0,
|
temperature=0.0,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -305,7 +303,7 @@ class ProcessingConfig(ConfigModel):
|
||||||
title_model: ModelConfig = Field(
|
title_model: ModelConfig = Field(
|
||||||
default_factory=lambda: ModelConfig(
|
default_factory=lambda: ModelConfig(
|
||||||
provider="ollama",
|
provider="ollama",
|
||||||
name="qwen3.8",
|
name="gpt-oss",
|
||||||
enable_thinking=False,
|
enable_thinking=False,
|
||||||
temperature=0.3,
|
temperature=0.3,
|
||||||
max_tokens=100,
|
max_tokens=100,
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,6 @@ In both cases:
|
||||||
- Results without doc_item_refs pass through unexpanded
|
- 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.chunk import SearchResult
|
||||||
from haiku.rag.store.models.document_item import DocumentItem
|
from haiku.rag.store.models.document_item import DocumentItem
|
||||||
|
|
||||||
|
|
@ -490,77 +488,3 @@ def expand_with_items(
|
||||||
final_results.append(built)
|
final_results.append(built)
|
||||||
|
|
||||||
return final_results + passthrough
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -42,20 +42,6 @@ def vlm_api_headers(model: "ModelConfig") -> dict[str, str]:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def vlm_api_params(model: "ModelConfig", max_tokens: int) -> dict[str, object]:
|
|
||||||
"""Request body fields docling posts alongside the picture."""
|
|
||||||
from haiku.rag.utils import reasoning_effort
|
|
||||||
|
|
||||||
params: dict[str, object] = {
|
|
||||||
"model": model.name,
|
|
||||||
"max_completion_tokens": max_tokens,
|
|
||||||
}
|
|
||||||
effort = reasoning_effort(model)
|
|
||||||
if effort is not None:
|
|
||||||
params["reasoning_effort"] = effort
|
|
||||||
return params
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentConverter(ABC):
|
class DocumentConverter(ABC):
|
||||||
"""Abstract base class for document converters.
|
"""Abstract base class for document converters.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ from haiku.rag.config import AppConfig
|
||||||
from haiku.rag.converters.base import (
|
from haiku.rag.converters.base import (
|
||||||
DocumentConverter,
|
DocumentConverter,
|
||||||
vlm_api_headers,
|
vlm_api_headers,
|
||||||
vlm_api_params,
|
|
||||||
vlm_api_url,
|
vlm_api_url,
|
||||||
)
|
)
|
||||||
from haiku.rag.converters.text_utils import TextFileHandler, docling_safe_name
|
from haiku.rag.converters.text_utils import TextFileHandler, docling_safe_name
|
||||||
|
|
@ -154,7 +153,10 @@ class DoclingLocalConverter(DocumentConverter):
|
||||||
pipeline_options.picture_description_options = PictureDescriptionApiOptions(
|
pipeline_options.picture_description_options = PictureDescriptionApiOptions(
|
||||||
url=AnyUrl(vlm_api_url(self.config, pic_desc.model)),
|
url=AnyUrl(vlm_api_url(self.config, pic_desc.model)),
|
||||||
headers=vlm_api_headers(pic_desc.model),
|
headers=vlm_api_headers(pic_desc.model),
|
||||||
params=vlm_api_params(pic_desc.model, pic_desc.max_tokens),
|
params=dict(
|
||||||
|
model=pic_desc.model.name,
|
||||||
|
max_completion_tokens=pic_desc.max_tokens,
|
||||||
|
),
|
||||||
prompt=self.config.prompts.picture_description,
|
prompt=self.config.prompts.picture_description,
|
||||||
timeout=pic_desc.timeout,
|
timeout=pic_desc.timeout,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ from haiku.rag.config import AppConfig
|
||||||
from haiku.rag.converters.base import (
|
from haiku.rag.converters.base import (
|
||||||
DocumentConverter,
|
DocumentConverter,
|
||||||
vlm_api_headers,
|
vlm_api_headers,
|
||||||
vlm_api_params,
|
|
||||||
vlm_api_url,
|
vlm_api_url,
|
||||||
)
|
)
|
||||||
from haiku.rag.converters.text_utils import TextFileHandler, docling_safe_name
|
from haiku.rag.converters.text_utils import TextFileHandler, docling_safe_name
|
||||||
|
|
@ -111,7 +110,10 @@ class DoclingServeConverter(DocumentConverter):
|
||||||
picture_description_api = {
|
picture_description_api = {
|
||||||
"url": vlm_api_url(self.config, pic_desc.model),
|
"url": vlm_api_url(self.config, pic_desc.model),
|
||||||
"headers": vlm_api_headers(pic_desc.model),
|
"headers": vlm_api_headers(pic_desc.model),
|
||||||
"params": vlm_api_params(pic_desc.model, pic_desc.max_tokens),
|
"params": {
|
||||||
|
"model": pic_desc.model.name,
|
||||||
|
"max_completion_tokens": pic_desc.max_tokens,
|
||||||
|
},
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"timeout": pic_desc.timeout,
|
"timeout": pic_desc.timeout,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,168 +1,66 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import AsyncExitStack, asynccontextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
from importlib import metadata
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Annotated
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastmcp import FastMCP
|
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.client import HaikuRAG
|
||||||
from haiku.rag.config import AppConfig, get_config
|
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.store.models import Document, SearchResult
|
||||||
from haiku.rag.store.schema import DocumentMetaRecord
|
from haiku.rag.tools.document import DocumentInfo
|
||||||
from haiku.rag.tools.document import DocumentInfo, DocumentSection, OutlineNode
|
from haiku.rag.utils import format_citations
|
||||||
from haiku.rag.tools.search import collect_pictures
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from haiku.rag.client.scope import DatabaseScope
|
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 _read_only(title: str) -> ToolAnnotations:
|
def _decode_images(images_base64: list[str] | None) -> list[bytes] | None:
|
||||||
return ToolAnnotations(title=title, read_only_hint=True, open_world_hint=False)
|
if not 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
|
return None
|
||||||
|
import base64
|
||||||
|
|
||||||
|
return [base64.b64decode(b64, validate=True) for b64 in images_base64]
|
||||||
|
|
||||||
|
|
||||||
def create_mcp_server(
|
def create_mcp_server(
|
||||||
db_path: Path | None = None, config: AppConfig | None = None
|
db_path: Path | None = None,
|
||||||
|
config: AppConfig | None = None,
|
||||||
|
read_only: bool = False,
|
||||||
) -> FastMCP:
|
) -> FastMCP:
|
||||||
"""Create an MCP server over the databases the configuration places.
|
"""Create an MCP server over one database.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db_path: Path to the database file, where `config` places none; or
|
db_path: Path to the database file, where `config` places none; or
|
||||||
None to serve the databases the configuration places. Beside
|
None to serve the database the configuration places. Beside
|
||||||
`lancedb.databases` a path raises `AmbiguousDatabaseError`.
|
`lancedb.databases` a path raises `AmbiguousDatabaseError`.
|
||||||
config: Configuration to use.
|
config: Configuration to use.
|
||||||
|
read_only: If True, write tools (add_document_*, delete_document) are not registered.
|
||||||
"""
|
"""
|
||||||
from haiku.rag.client.scope import DatabaseScope
|
from haiku.rag.client.scope import DatabaseScope
|
||||||
|
|
||||||
config = config if config is not None else get_config()
|
config = config if config is not None else get_config()
|
||||||
return _covering(DatabaseScope.resolve(config, database_path=db_path), config)
|
return _covering(
|
||||||
|
DatabaseScope.resolve(config, database_path=db_path), config, read_only
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
|
def _covering(scope: "DatabaseScope", config: AppConfig, read_only: bool) -> FastMCP:
|
||||||
"""An MCP server over databases someone already resolved.
|
"""An MCP server over databases someone already resolved.
|
||||||
|
|
||||||
Internal, as ``HaikuRAG._covering`` is: the public factory takes a path and
|
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
|
resolves it, which is its own job. A caller that resolved already passes the
|
||||||
scope, so the configured name survives, which results carry as ``source``.
|
scope, so the configured name survives, which results and citations 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
|
client: HaikuRAG | None = None
|
||||||
stack = AsyncExitStack()
|
stack = AsyncExitStack()
|
||||||
client_lock = asyncio.Lock()
|
client_lock = asyncio.Lock()
|
||||||
|
|
@ -178,7 +76,7 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
|
||||||
async with client_lock:
|
async with client_lock:
|
||||||
if client is None:
|
if client is None:
|
||||||
client = await stack.enter_async_context(
|
client = await stack.enter_async_context(
|
||||||
HaikuRAG._covering(scope, config, read_only=True)
|
HaikuRAG._covering(scope, config, read_only=read_only)
|
||||||
)
|
)
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
@ -197,53 +95,90 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
|
||||||
finally:
|
finally:
|
||||||
client = None
|
client = None
|
||||||
|
|
||||||
# Explicit: the setting is also read from the environment, and the contract
|
mcp = FastMCP("haiku-rag", lifespan=lifespan)
|
||||||
# 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
@mcp.tool(annotations=_read_only("Search documents"))
|
# Write tools - only registered when not in read-only mode
|
||||||
async def search_documents(
|
if not read_only:
|
||||||
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.
|
|
||||||
|
|
||||||
Use this first for any question the documents might answer; it needs
|
@mcp.tool()
|
||||||
no model and is the cheapest call. Results come best first, each with
|
async def add_document_from_file(
|
||||||
its rank, `Document ID`, `Collection` when the server covers several,
|
file_path: str,
|
||||||
the document title, section headings, the matched chunk's metadata
|
metadata: dict[str, Any] | None = None,
|
||||||
when it has any, and the matching passage expanded to its section;
|
title: str | None = None,
|
||||||
pass the id and collection to the document tools. Pictures in the
|
) -> str | None:
|
||||||
results follow as images, each labelled with its result. Ranks, not scores,
|
"""Add a document to the RAG system from a file path."""
|
||||||
are the signal: scores are not comparable across queries. If nothing
|
try:
|
||||||
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.
|
|
||||||
"""
|
|
||||||
rag = await _client()
|
rag = await _client()
|
||||||
results = await rag.search(
|
result = await rag.create_document_from_source(
|
||||||
query,
|
Path(file_path), title=title, metadata=metadata or {}
|
||||||
limit=limit,
|
|
||||||
filter=filter,
|
|
||||||
include_images=include_images,
|
|
||||||
sources=sources,
|
|
||||||
)
|
)
|
||||||
return _search_result(await rag.expand_context(results), rag.covers_multiple)
|
# 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()
|
||||||
|
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).
|
||||||
|
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rag = await _client()
|
||||||
|
return await rag.search(query, limit=limit, include_images=include_images)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
# Image-as-query tool, only registered when the configured embedder
|
# Image-as-query tool, only registered when the configured embedder
|
||||||
# supports image embeddings. Probed at server-build time when no Store is
|
# supports image embeddings. Probed at server-build time when no Store is
|
||||||
|
|
@ -253,208 +188,123 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
|
||||||
|
|
||||||
if get_embedder(config).supports_images:
|
if get_embedder(config).supports_images:
|
||||||
|
|
||||||
@mcp.tool(annotations=_read_only("Search documents by image"))
|
@mcp.tool()
|
||||||
async def search_documents_by_image(
|
async def search_documents_by_image(
|
||||||
image_base64: str,
|
image_base64: str,
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
include_images: bool = True,
|
include_images: bool = True,
|
||||||
filter: Filter = None,
|
) -> list[SearchResult]:
|
||||||
sources: Sources = None,
|
"""Search the RAG system using an image as the query.
|
||||||
) -> ToolResult:
|
|
||||||
"""Search the knowledge base with an image as the query.
|
|
||||||
|
|
||||||
Use this when the question is about a picture rather than words.
|
``image_base64`` is a base64-encoded image (PNG/JPEG bytes). The
|
||||||
The image is embedded and matched against document text and
|
image is embedded via the configured multimodal embedder and the
|
||||||
figures by vector similarity alone. Results have the shape of
|
chunks table is searched vector-only. ``include_images`` controls
|
||||||
`search_documents` results.
|
whether picture bytes are attached to picture-labeled 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.
|
|
||||||
"""
|
"""
|
||||||
raw = _decode_image(image_base64)
|
import base64
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(image_base64)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
rag = await _client()
|
rag = await _client()
|
||||||
results = await rag.search(
|
return await rag.search(raw, limit=limit, include_images=include_images)
|
||||||
raw,
|
except Exception:
|
||||||
limit=limit,
|
return []
|
||||||
filter=filter,
|
|
||||||
include_images=include_images,
|
|
||||||
sources=sources,
|
|
||||||
)
|
|
||||||
return _search_result(
|
|
||||||
await rag.expand_context(results), rag.covers_multiple
|
|
||||||
)
|
|
||||||
|
|
||||||
@mcp.tool(annotations=_read_only("Get document"))
|
@mcp.tool()
|
||||||
async def get_document(document_id: str, source: str | None = None) -> Document:
|
async def get_document(document_id: str) -> Document | None:
|
||||||
"""Read one document whole, in reading order.
|
"""Get a document by its ID."""
|
||||||
|
try:
|
||||||
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()
|
rag = await _client()
|
||||||
document = await rag.get_document_by_id(document_id, source)
|
return await rag.get_document_by_id(document_id)
|
||||||
if document is None:
|
except Exception:
|
||||||
raise ToolError(f"No document with id {document_id!r}")
|
return None
|
||||||
return document
|
|
||||||
|
|
||||||
async def _items_of(document_id: str, source: str | None) -> list["DocumentItem"]:
|
@mcp.tool()
|
||||||
"""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(
|
async def list_documents(
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
offset: int | None = None,
|
offset: int | None = None,
|
||||||
filter: Filter = None,
|
filter: str | None = None,
|
||||||
) -> list[DocumentInfo]:
|
) -> list[DocumentInfo]:
|
||||||
"""List what the knowledge base holds.
|
"""List all documents with optional pagination and filtering.
|
||||||
|
|
||||||
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:
|
Args:
|
||||||
limit: How many documents to return.
|
limit: Maximum number of documents to return.
|
||||||
offset: How many documents to skip, for paging.
|
offset: Number of documents to skip.
|
||||||
|
filter: Optional SQL WHERE clause to filter documents.
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
rag = await _client()
|
rag = await _client()
|
||||||
documents = await rag.list_documents(limit, offset, filter)
|
documents = await rag.list_documents(limit, offset, filter)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
DocumentInfo(
|
DocumentInfo(
|
||||||
id=doc.id,
|
id=doc.id,
|
||||||
title=doc.title or "Untitled",
|
title=doc.title or "Untitled",
|
||||||
uri=doc.uri or "",
|
uri=doc.uri or "",
|
||||||
created=doc.created_at.strftime("%Y-%m-%d"),
|
created=doc.created_at.strftime("%Y-%m-%d"),
|
||||||
source=doc.source,
|
|
||||||
metadata=doc.metadata,
|
|
||||||
)
|
)
|
||||||
for doc in documents
|
for doc in documents
|
||||||
]
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
@mcp.tool(annotations=_read_only("Run code over the documents"))
|
@mcp.tool()
|
||||||
async def execute_code(
|
async def ask_question(
|
||||||
code: str, filter: Filter = None, sources: Sources = None
|
question: str,
|
||||||
|
cite: bool = False,
|
||||||
|
images_base64: list[str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Run a Python program over the documents and return what it printed.
|
"""Ask a question using the QA agent.
|
||||||
|
|
||||||
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:
|
Args:
|
||||||
code: The program. Use `await` on search and list_documents.
|
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.
|
||||||
"""
|
"""
|
||||||
rag = await _client()
|
|
||||||
sandbox = Sandbox._covering(
|
|
||||||
scope, config, AnalysisContext(filter=filter, sources=sources), rag=rag
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
result = await sandbox.execute(code)
|
images = _decode_images(images_base64)
|
||||||
finally:
|
rag = await _client()
|
||||||
await sandbox.close()
|
answer, citations = await rag.ask(question, images=images)
|
||||||
if not result.success:
|
if cite and citations:
|
||||||
raise ToolError(
|
answer += "\n\n" + format_citations(citations)
|
||||||
f"{result.stderr}{recovery_hint(result.stderr)}"
|
return answer
|
||||||
f"\n\nOutput: {result.stdout}"
|
except Exception as e:
|
||||||
)
|
return f"Error answering question: {e!s}"
|
||||||
return result.stdout or "No output."
|
|
||||||
|
@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}"
|
||||||
|
|
||||||
return mcp
|
return mcp
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
from haiku.rag.sandbox.dependencies import AnalysisContext
|
from haiku.rag.sandbox.dependencies import AnalysisContext
|
||||||
from haiku.rag.sandbox.models import AnalysisResult
|
from haiku.rag.sandbox.models import AnalysisResult
|
||||||
from haiku.rag.sandbox.sandbox import Sandbox, SandboxResult, recovery_hint
|
from haiku.rag.sandbox.sandbox import Sandbox, SandboxResult
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AnalysisContext",
|
"AnalysisContext",
|
||||||
"AnalysisResult",
|
"AnalysisResult",
|
||||||
"Sandbox",
|
"Sandbox",
|
||||||
"SandboxResult",
|
"SandboxResult",
|
||||||
"recovery_hint",
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,8 @@ from pydantic_monty import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from haiku.rag.config.models import AppConfig
|
from haiku.rag.config.models import AppConfig
|
||||||
from haiku.rag.context import build_toc
|
|
||||||
from haiku.rag.sandbox.dependencies import AnalysisContext
|
from haiku.rag.sandbox.dependencies import AnalysisContext
|
||||||
from haiku.rag.store.models.chunk import Chunk, SearchResult
|
from haiku.rag.store.models.chunk import SearchResult
|
||||||
from haiku.rag.store.models.document_item import PICTURE_REF_PREFIX, DocumentItem
|
from haiku.rag.store.models.document_item import PICTURE_REF_PREFIX, DocumentItem
|
||||||
from haiku.rag.utils import gather_all
|
from haiku.rag.utils import gather_all
|
||||||
|
|
||||||
|
|
@ -30,9 +29,6 @@ if TYPE_CHECKING:
|
||||||
from haiku.rag.client.scope import DatabaseScope
|
from haiku.rag.client.scope import DatabaseScope
|
||||||
|
|
||||||
|
|
||||||
_MAX_HOST_CALLS = 10_000_000
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SandboxResult:
|
class SandboxResult:
|
||||||
"""Result of executing code in the sandbox."""
|
"""Result of executing code in the sandbox."""
|
||||||
|
|
@ -42,19 +38,79 @@ class SandboxResult:
|
||||||
success: bool
|
success: bool
|
||||||
|
|
||||||
|
|
||||||
def recovery_hint(stderr: str) -> str:
|
def _build_toc(
|
||||||
"""Name the workaround for sandbox limits models trip over repeatedly.
|
items: list["DocumentItem"],
|
||||||
|
chunk_index: dict[str, list[str]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Build a nested section tree from items in position order.
|
||||||
|
|
||||||
The instructions already say file objects are not iterable, and models write
|
Each ``section_header`` with ``heading_level > 0`` becomes a node. Nesting
|
||||||
``for line in open(...)`` regardless. Carrying the fix in the error gives
|
follows the explicit levels: a header pops the stack until the top is at
|
||||||
them something to act on for the retry.
|
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).
|
||||||
"""
|
"""
|
||||||
if "TextIOWrapper" in stderr and "not iterable" in stderr:
|
# Defensive: every consumer is supposed to pass items in position order,
|
||||||
return (
|
# but the end_exclusive lookahead below silently miscomputes section
|
||||||
"\n\nHint: file objects cannot be iterated here. Read lines with "
|
# boundaries if it's not — better to sort once than trust the caller.
|
||||||
'.readlines() or .read().split("\\n").'
|
items = sorted(items, key=lambda i: i.position)
|
||||||
)
|
headers: list[DocumentItem] = [
|
||||||
return ""
|
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
|
||||||
|
|
||||||
|
|
||||||
class Sandbox:
|
class Sandbox:
|
||||||
|
|
@ -64,8 +120,7 @@ class Sandbox:
|
||||||
The interpreter runs in a subprocess worker checked out of an ``AsyncMonty``
|
The interpreter runs in a subprocess worker checked out of an ``AsyncMonty``
|
||||||
pool. External functions (search, list_documents) are called by Monty code
|
pool. External functions (search, list_documents) are called by Monty code
|
||||||
using ``await`` and resolved asynchronously on the host. Documents are
|
using ``await`` and resolved asynchronously on the host. Documents are
|
||||||
exposed via a virtual filesystem at ``/documents/{id}/``: ``metadata.json``,
|
exposed via a virtual filesystem at ``/documents/{id}/``.
|
||||||
``content.txt``, ``items.jsonl``, ``chunks.jsonl`` and ``toc.json``.
|
|
||||||
|
|
||||||
The session persists across ``execute()`` calls within the same Sandbox
|
The session persists across ``execute()`` calls within the same Sandbox
|
||||||
instance — variables carry over. Call ``close()`` to return the worker to
|
instance — variables carry over. Call ``close()`` to return the worker to
|
||||||
|
|
@ -95,7 +150,6 @@ class Sandbox:
|
||||||
_doc_items: dict[str, list["DocumentItem"]]
|
_doc_items: dict[str, list["DocumentItem"]]
|
||||||
_doc_chunk_index: dict[str, dict[str, list[str]]]
|
_doc_chunk_index: dict[str, dict[str, list[str]]]
|
||||||
_items_jsonl_cache: dict[str, str]
|
_items_jsonl_cache: dict[str, str]
|
||||||
_chunks_jsonl_cache: dict[str, str]
|
|
||||||
_toc_json_cache: dict[str, str]
|
_toc_json_cache: dict[str, str]
|
||||||
_opened: "HaikuRAG | None"
|
_opened: "HaikuRAG | None"
|
||||||
_pool: AsyncMonty | None
|
_pool: AsyncMonty | None
|
||||||
|
|
@ -162,7 +216,6 @@ class Sandbox:
|
||||||
self._doc_items = {}
|
self._doc_items = {}
|
||||||
self._doc_chunk_index = {}
|
self._doc_chunk_index = {}
|
||||||
self._items_jsonl_cache = {}
|
self._items_jsonl_cache = {}
|
||||||
self._chunks_jsonl_cache = {}
|
|
||||||
self._toc_json_cache = {}
|
self._toc_json_cache = {}
|
||||||
self._pool = None
|
self._pool = None
|
||||||
self._session = None
|
self._session = None
|
||||||
|
|
@ -275,42 +328,13 @@ class Sandbox:
|
||||||
assert self._loop is not None, (
|
assert self._loop is not None, (
|
||||||
"VFS reads happen during execute(); the loop must be captured first."
|
"VFS reads happen during execute(); the loop must be captured first."
|
||||||
)
|
)
|
||||||
if self._past_deadline():
|
if self._deadline is not None and self._loop.time() > self._deadline:
|
||||||
coro.close()
|
coro.close()
|
||||||
raise self._time_limit()
|
raise TimeoutError(
|
||||||
return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
|
"time limit exceeded: no further document reads after "
|
||||||
|
|
||||||
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"
|
f"{self._config.analysis.code_timeout}s"
|
||||||
)
|
)
|
||||||
|
return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
|
||||||
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:
|
async def _discard_session(self) -> None:
|
||||||
"""Drop a session whose worker is gone.
|
"""Drop a session whose worker is gone.
|
||||||
|
|
@ -347,7 +371,6 @@ class Sandbox:
|
||||||
context = self._context
|
context = self._context
|
||||||
|
|
||||||
async def search(query: str, limit: int = 10) -> list[dict[str, Any]]:
|
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
|
# Picture bytes are deliberately not attached to in-code search
|
||||||
# results: the Monty interpreter has no PIL/base64/hashlib, so the
|
# results: the Monty interpreter has no PIL/base64/hashlib, so the
|
||||||
# agent's Python can't do anything with them. The driving model
|
# agent's Python can't do anything with them. The driving model
|
||||||
|
|
@ -381,13 +404,11 @@ class Sandbox:
|
||||||
"doc_item_refs": r.doc_item_refs,
|
"doc_item_refs": r.doc_item_refs,
|
||||||
"labels": r.labels,
|
"labels": r.labels,
|
||||||
"picture_refs": picture_refs,
|
"picture_refs": picture_refs,
|
||||||
"chunk_meta": r.chunk_meta,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
async def list_documents() -> list[dict[str, Any]]:
|
async def list_documents() -> list[dict[str, Any]]:
|
||||||
self._check_deadline()
|
|
||||||
docs, _ = await self._documents()
|
docs, _ = await self._documents()
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
@ -396,7 +417,6 @@ class Sandbox:
|
||||||
"uri": d.uri,
|
"uri": d.uri,
|
||||||
"created_at": str(d.created_at),
|
"created_at": str(d.created_at),
|
||||||
"source": d.source,
|
"source": d.source,
|
||||||
"metadata": d.metadata,
|
|
||||||
}
|
}
|
||||||
for d in docs
|
for d in docs
|
||||||
]
|
]
|
||||||
|
|
@ -413,7 +433,6 @@ class Sandbox:
|
||||||
- metadata.json: CallbackFile (eager, small)
|
- metadata.json: CallbackFile (eager, small)
|
||||||
- content.txt: CallbackFile (lazy, can be large)
|
- content.txt: CallbackFile (lazy, can be large)
|
||||||
- items.jsonl: CallbackFile (lazy, bulk-cached)
|
- items.jsonl: CallbackFile (lazy, bulk-cached)
|
||||||
- chunks.jsonl: CallbackFile (lazy, bulk-cached)
|
|
||||||
- toc.json: CallbackFile (lazy, bulk-cached)
|
- toc.json: CallbackFile (lazy, bulk-cached)
|
||||||
"""
|
"""
|
||||||
files: list[CallbackFile] = []
|
files: list[CallbackFile] = []
|
||||||
|
|
@ -488,31 +507,6 @@ class Sandbox:
|
||||||
|
|
||||||
return read_items
|
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(
|
def _make_toc_reader(
|
||||||
did: str,
|
did: str,
|
||||||
) -> Callable[["PurePosixPath"], str]:
|
) -> Callable[["PurePosixPath"], str]:
|
||||||
|
|
@ -526,7 +520,7 @@ class Sandbox:
|
||||||
{
|
{
|
||||||
"doc_id": did,
|
"doc_id": did,
|
||||||
"title": doc_titles.get(did),
|
"title": doc_titles.get(did),
|
||||||
"tree": build_toc(items, chunk_index),
|
"tree": _build_toc(items, chunk_index),
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
|
|
@ -547,7 +541,6 @@ class Sandbox:
|
||||||
"title": doc.title,
|
"title": doc.title,
|
||||||
"uri": doc.uri,
|
"uri": doc.uri,
|
||||||
"created_at": str(doc.created_at),
|
"created_at": str(doc.created_at),
|
||||||
"metadata": doc.metadata,
|
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
|
|
@ -557,7 +550,7 @@ class Sandbox:
|
||||||
files.append(
|
files.append(
|
||||||
CallbackFile(
|
CallbackFile(
|
||||||
f"{doc_dir}/metadata.json",
|
f"{doc_dir}/metadata.json",
|
||||||
read=self._timed(lambda _path, text=metadata: text),
|
read=lambda _path, text=metadata: text,
|
||||||
write=_deny_write,
|
write=_deny_write,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -578,21 +571,14 @@ class Sandbox:
|
||||||
files.append(
|
files.append(
|
||||||
CallbackFile(
|
CallbackFile(
|
||||||
f"{doc_dir}/content.txt",
|
f"{doc_dir}/content.txt",
|
||||||
read=self._timed(_make_content_reader(doc_id)),
|
read=_make_content_reader(doc_id),
|
||||||
write=_deny_write,
|
write=_deny_write,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
files.append(
|
files.append(
|
||||||
CallbackFile(
|
CallbackFile(
|
||||||
f"{doc_dir}/items.jsonl",
|
f"{doc_dir}/items.jsonl",
|
||||||
read=self._timed(_make_items_reader(doc_id)),
|
read=_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,
|
write=_deny_write,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -603,7 +589,7 @@ class Sandbox:
|
||||||
files.append(
|
files.append(
|
||||||
CallbackFile(
|
CallbackFile(
|
||||||
f"{doc_dir}/toc.json",
|
f"{doc_dir}/toc.json",
|
||||||
read=self._timed(_make_toc_reader(doc_id)),
|
read=_make_toc_reader(doc_id),
|
||||||
write=_deny_write,
|
write=_deny_write,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -615,19 +601,12 @@ class Sandbox:
|
||||||
|
|
||||||
Monty spends ``max_duration_secs`` across the session's whole life, and
|
Monty spends ``max_duration_secs`` across the session's whole life, and
|
||||||
the session is reused so variables persist between calls: the budget
|
the session is reused so variables persist between calls: the budget
|
||||||
covers the whole run. ``code_timeout`` is enforced per call elsewhere: past
|
covers the whole run. ``code_timeout`` is enforced per call elsewhere: the read
|
||||||
its deadline no further host call starts (``_check_deadline``), and the
|
deadline in ``_run_on_loop`` bounds a call that reads, and the pool's
|
||||||
pool's ``request_timeout`` bounds compute.
|
``request_timeout`` bounds one that computes.
|
||||||
|
|
||||||
``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
|
analysis = self._config.analysis
|
||||||
return {
|
return {"max_duration_secs": analysis.code_timeout * analysis.max_executions}
|
||||||
"max_duration_secs": analysis.code_timeout * analysis.max_executions,
|
|
||||||
"max_suspensions": _MAX_HOST_CALLS,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _ensure_initialized(self) -> tuple[AsyncMontySession, OSAccess]:
|
async def _ensure_initialized(self) -> tuple[AsyncMontySession, OSAccess]:
|
||||||
"""Check out a worker session and build the VFS on first use."""
|
"""Check out a worker session and build the VFS on first use."""
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import json
|
|
||||||
from typing import TYPE_CHECKING, Literal
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, PrivateAttr
|
from pydantic import BaseModel, PrivateAttr
|
||||||
|
|
@ -144,9 +143,8 @@ class SearchResult(BaseModel):
|
||||||
consumers (UIs). Never part of ``format_for_agent`` output.
|
consumers (UIs). Never part of ``format_for_agent`` output.
|
||||||
|
|
||||||
``chunk_meta`` is the anchor chunk's unparsed ``Chunk.metadata`` and does not
|
``chunk_meta`` is the anchor chunk's unparsed ``Chunk.metadata`` and does not
|
||||||
include the metadata of any other chunks merged with it. Left out of
|
include the metadata of any other chunks merged with it. Never part of
|
||||||
``format_for_agent`` output unless ``include_chunk_meta`` asks for its custom
|
``format_for_agent`` output.
|
||||||
keys.
|
|
||||||
|
|
||||||
``source`` names the database a result came from: the name from
|
``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
|
``lancedb.databases`` or a path's stem, never a path or URI, so a location
|
||||||
|
|
@ -204,8 +202,6 @@ class SearchResult(BaseModel):
|
||||||
total: int | None = None,
|
total: int | None = None,
|
||||||
*,
|
*,
|
||||||
include_collection: bool = False,
|
include_collection: bool = False,
|
||||||
include_document_id: bool = False,
|
|
||||||
include_chunk_meta: bool = False,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Format this search result for inclusion in agent context.
|
"""Format this search result for inclusion in agent context.
|
||||||
|
|
||||||
|
|
@ -219,11 +215,7 @@ class SearchResult(BaseModel):
|
||||||
|
|
||||||
`include_collection` is the caller's decision, not this result's: a
|
`include_collection` is the caller's decision, not this result's: a
|
||||||
search spanning one collection has nothing to distinguish, whether or
|
search spanning one collection has nothing to distinguish, whether or
|
||||||
not that collection is named. `include_document_id` is for a reader
|
not that collection is named.
|
||||||
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:
|
if rank is not None and total is not None:
|
||||||
parts = [f"[{self.chunk_id}] [rank {rank} of {total}]"]
|
parts = [f"[{self.chunk_id}] [rank {rank} of {total}]"]
|
||||||
|
|
@ -232,9 +224,6 @@ class SearchResult(BaseModel):
|
||||||
else:
|
else:
|
||||||
parts = [f"[{self.chunk_id}] (score: {self.score:.2f})"]
|
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:
|
if include_collection and self.source:
|
||||||
parts.append(f"Collection: {self.source}")
|
parts.append(f"Collection: {self.source}")
|
||||||
|
|
||||||
|
|
@ -253,16 +242,6 @@ class SearchResult(BaseModel):
|
||||||
if primary_label:
|
if primary_label:
|
||||||
parts.append(f"Type: {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
|
# Surface picture captions when present. Order matches the binary
|
||||||
# attachments emitted by build_image_content_from_results, so the model
|
# attachments emitted by build_image_content_from_results, so the model
|
||||||
# can correlate caption ↔ attached image by position (BinaryContent
|
# can correlate caption ↔ attached image by position (BinaryContent
|
||||||
|
|
|
||||||
|
|
@ -27,27 +27,6 @@ class DocumentInfo(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
uri: str
|
uri: str
|
||||||
created: 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):
|
class DocumentListResponse(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -52,16 +52,31 @@ def decode_picture(data: bytes, self_ref: str) -> BinaryContent | None:
|
||||||
return BinaryContent(data=data, media_type="image/png", identifier=self_ref)
|
return BinaryContent(data=data, media_type="image/png", identifier=self_ref)
|
||||||
|
|
||||||
|
|
||||||
def collect_pictures(
|
def build_image_content_from_results(
|
||||||
results: list[SearchResult], exclude: AbstractSet[PictureKey] = frozenset()
|
results: list[SearchResult],
|
||||||
) -> tuple[list[tuple[str | None, str | None, str, BinaryContent]], set[PictureKey]]:
|
include_collection: bool = False,
|
||||||
"""Every distinct, decodable picture attached to ``results``, in order.
|
exclude: AbstractSet[PictureKey] = frozenset(),
|
||||||
|
) -> tuple[list[str | BinaryContent], set[PictureKey]]:
|
||||||
|
"""Decode and validate picture bytes attached to search results, labelled.
|
||||||
|
|
||||||
Returns ``(source, chunk_id, self_ref, picture)`` per picture and the
|
Returns the labelled content and the ``PictureKey`` of every picture it
|
||||||
``PictureKey`` of each. Dedup keyed on ``PictureKey`` so the same picture in
|
emitted. Dedup keyed on ``PictureKey`` so the same picture in
|
||||||
different chunks is emitted once, and a copy in another collection is its
|
different chunks is sent once, and a copy in another collection is its
|
||||||
own; ``exclude`` seeds that dedup with pictures already sent. Pictures that
|
own; ``exclude`` seeds that dedup with pictures already sent. Pictures that fail
|
||||||
fail ``PIL.Image.verify()`` are skipped.
|
``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.
|
||||||
"""
|
"""
|
||||||
collected: list[tuple[str | None, str | None, str, BinaryContent]] = []
|
collected: list[tuple[str | None, str | None, str, BinaryContent]] = []
|
||||||
seen: set[PictureKey] = set(exclude)
|
seen: set[PictureKey] = set(exclude)
|
||||||
|
|
@ -79,33 +94,7 @@ def collect_pictures(
|
||||||
collected.append((result.source, result.chunk_id, self_ref, picture))
|
collected.append((result.source, result.chunk_id, self_ref, picture))
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
emitted.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] = []
|
content: list[str | BinaryContent] = []
|
||||||
total = len(collected)
|
total = len(collected)
|
||||||
for position, (source, chunk_id, self_ref, picture) in enumerate(collected, 1):
|
for position, (source, chunk_id, self_ref, picture) in enumerate(collected, 1):
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import sys
|
||||||
from collections.abc import Awaitable
|
from collections.abc import Awaitable
|
||||||
from importlib import metadata
|
from importlib import metadata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Literal, NoReturn, cast
|
from typing import TYPE_CHECKING, Any, NoReturn, cast
|
||||||
|
|
||||||
from packaging.version import Version, parse
|
from packaging.version import Version, parse
|
||||||
|
|
||||||
|
|
@ -41,7 +41,7 @@ def parse_model_option(value: str) -> "ModelConfig":
|
||||||
parts = value.split(":", 1)
|
parts = value.split(":", 1)
|
||||||
if len(parts) != 2 or not parts[0] or not parts[1]:
|
if len(parts) != 2 or not parts[0] or not parts[1]:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Invalid model format '{value}'. Expected 'provider:name' (e.g. 'ollama:qwen3.8')."
|
f"Invalid model format '{value}'. Expected 'provider:name' (e.g. 'ollama:gpt-oss')."
|
||||||
)
|
)
|
||||||
return ModelConfig(provider=parts[0], name=parts[1])
|
return ModelConfig(provider=parts[0], name=parts[1])
|
||||||
|
|
||||||
|
|
@ -182,20 +182,6 @@ _OPENAI_COMPAT_PROFILE: "OpenAIModelProfile" = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def reasoning_effort(
|
|
||||||
model_config: "ModelConfig",
|
|
||||||
) -> Literal["none", "low", "high"] | None:
|
|
||||||
"""OpenAI `reasoning_effort` for a model config, or None when unset.
|
|
||||||
|
|
||||||
"low" is gpt-oss's floor; its template rejects "none".
|
|
||||||
"""
|
|
||||||
if model_config.enable_thinking is None:
|
|
||||||
return None
|
|
||||||
if model_config.enable_thinking:
|
|
||||||
return "high"
|
|
||||||
return "low" if model_config.name == "gpt-oss" else "none"
|
|
||||||
|
|
||||||
|
|
||||||
def get_model(
|
def get_model(
|
||||||
model_config: "ModelConfig",
|
model_config: "ModelConfig",
|
||||||
app_config: "AppConfig | None" = None,
|
app_config: "AppConfig | None" = None,
|
||||||
|
|
@ -227,9 +213,12 @@ def get_model(
|
||||||
if provider == "ollama":
|
if provider == "ollama":
|
||||||
model_settings = None
|
model_settings = None
|
||||||
|
|
||||||
effort = reasoning_effort(model_config)
|
# Apply thinking control for gpt-oss
|
||||||
if effort is not None:
|
if model == "gpt-oss" and model_config.enable_thinking is not None:
|
||||||
model_settings = OpenAIChatModelSettings(openai_reasoning_effort=effort)
|
if model_config.enable_thinking is False:
|
||||||
|
model_settings = OpenAIChatModelSettings(openai_reasoning_effort="low")
|
||||||
|
else:
|
||||||
|
model_settings = OpenAIChatModelSettings(openai_reasoning_effort="high")
|
||||||
|
|
||||||
model_settings = apply_common_settings(
|
model_settings = apply_common_settings(
|
||||||
model_settings, model_config, map_thinking=False
|
model_settings, model_config, map_thinking=False
|
||||||
|
|
@ -393,6 +382,43 @@ def _citation_label(c: "Citation") -> str:
|
||||||
return c.document_title or c.document_uri
|
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:
|
def truncated(text: str, limit: int) -> str:
|
||||||
"""The first `limit` characters of `text`, with `…` appended when anything
|
"""The first `limit` characters of `text`, with `…` appended when anything
|
||||||
was dropped. A cut result is `limit` characters plus the mark."""
|
was dropped. A cut result is `limit` characters plus the mark."""
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,12 @@ dependencies = [
|
||||||
"docling-core>=2.82.0,<3.0.0",
|
"docling-core>=2.82.0,<3.0.0",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"jinja2>=3.1.0",
|
"jinja2>=3.1.0",
|
||||||
"fastmcp>=4.0.2,<5.0.0",
|
"fastmcp>=3.3.0",
|
||||||
"lancedb==0.37.1",
|
"lancedb==0.37.1",
|
||||||
"pathspec>=1.0.4",
|
"pathspec>=1.0.4",
|
||||||
"pydantic>=2.12.5",
|
"pydantic>=2.12.5",
|
||||||
"pydantic-ai-slim[openai,logfire,ag-ui]>=2.18.0,<3.0.0",
|
"pydantic-ai-slim[openai,logfire,ag-ui]>=2.18.0,<3.0.0",
|
||||||
"pydantic-monty>=0.0.23",
|
"pydantic-monty>=0.0.19",
|
||||||
"pypdfium2>=5.0",
|
"pypdfium2>=5.0",
|
||||||
"python-dotenv>=1.2.2",
|
"python-dotenv>=1.2.2",
|
||||||
"pyyaml>=6.0.3",
|
"pyyaml>=6.0.3",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
{
|
|
||||||
"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."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"haiku-rag": {
|
|
||||||
"command": "haiku-rag",
|
|
||||||
"args": ["mcp", "--stdio"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
---
|
|
||||||
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,8 +2,7 @@
|
||||||
"""
|
"""
|
||||||
Version bumping script for haiku.rag workspace.
|
Version bumping script for haiku.rag workspace.
|
||||||
|
|
||||||
Updates version in all pyproject.toml files, both plugin manifests, and
|
Updates version in all pyproject.toml files and CHANGELOG.md.
|
||||||
CHANGELOG.md.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
@ -55,19 +54,6 @@ def update_example_dependencies(file_path: Path, new_version: str) -> None:
|
||||||
print(f"✓ Updated example dependencies in {file_path.relative_to(Path.cwd())}")
|
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:
|
def update_changelog(changelog_path: Path, new_version: str) -> None:
|
||||||
"""Update CHANGELOG.md with new version."""
|
"""Update CHANGELOG.md with new version."""
|
||||||
content = changelog_path.read_text()
|
content = changelog_path.read_text()
|
||||||
|
|
@ -136,16 +122,10 @@ def main():
|
||||||
root / "app" / "backend" / "pyproject.toml",
|
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"
|
changelog_file = root / "CHANGELOG.md"
|
||||||
|
|
||||||
# Check all files exist
|
# Check all files exist
|
||||||
for file in (
|
for file in pyproject_files + example_pyproject_files + [changelog_file]:
|
||||||
pyproject_files + example_pyproject_files + plugin_files + [changelog_file]
|
|
||||||
):
|
|
||||||
if not file.exists():
|
if not file.exists():
|
||||||
print(f"Error: {file} not found")
|
print(f"Error: {file} not found")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
@ -175,9 +155,6 @@ def main():
|
||||||
for file in example_pyproject_files:
|
for file in example_pyproject_files:
|
||||||
update_example_dependencies(file, new_version)
|
update_example_dependencies(file, new_version)
|
||||||
|
|
||||||
for file in plugin_files:
|
|
||||||
update_plugin_version(file, new_version)
|
|
||||||
|
|
||||||
# Update CHANGELOG.md
|
# Update CHANGELOG.md
|
||||||
update_changelog(changelog_file, new_version)
|
update_changelog(changelog_file, new_version)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,6 @@ embeddings:
|
||||||
""")
|
""")
|
||||||
os.environ["HAIKU_RAG_CONFIG_PATH"] = str(_test_config_path)
|
os.environ["HAIKU_RAG_CONFIG_PATH"] = str(_test_config_path)
|
||||||
|
|
||||||
# telemetry.configure() passes send_to_logfire="if-token-present" explicitly,
|
|
||||||
# which beats LOGFIRE_SEND_TO_LOGFIRE, so no token must resolve: drop the
|
|
||||||
# environment variable and point credentials discovery at an empty directory.
|
|
||||||
os.environ.pop("LOGFIRE_TOKEN", None)
|
|
||||||
os.environ["LOGFIRE_CREDENTIALS_DIR"] = tempfile.mkdtemp()
|
|
||||||
|
|
||||||
import pydantic_ai.models # noqa: E402
|
import pydantic_ai.models # noqa: E402
|
||||||
import pytest # noqa: E402
|
import pytest # noqa: E402
|
||||||
import yaml # noqa: E402
|
import yaml # noqa: E402
|
||||||
|
|
@ -108,7 +102,7 @@ def temp_yaml_config(tmp_path, monkeypatch):
|
||||||
"vector_dim": 2560,
|
"vector_dim": 2560,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"qa": {"model": {"provider": "ollama", "name": "qwen3.8"}},
|
"qa": {"model": {"provider": "ollama", "name": "gpt-oss"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
with open(config_file, "w") as f:
|
with open(config_file, "w") as f:
|
||||||
|
|
|
||||||
|
|
@ -1054,23 +1054,16 @@ async def test_breaker_isolates_sources(client, jobs, sync):
|
||||||
for _ in range(10):
|
for _ in range(10):
|
||||||
pool._breaker_for("bad").record_failure()
|
pool._breaker_for("bad").record_failure()
|
||||||
|
|
||||||
async def _good_jobs_drained():
|
|
||||||
while True:
|
|
||||||
done = await jobs.list_jobs(status=JobStatus.SUCCEEDED, limit=50)
|
|
||||||
if len(done) == 3:
|
|
||||||
return done
|
|
||||||
await asyncio.sleep(0.02)
|
|
||||||
|
|
||||||
await pool.start()
|
await pool.start()
|
||||||
try:
|
try:
|
||||||
succeeded = await asyncio.wait_for(_good_jobs_drained(), timeout=5.0)
|
await asyncio.sleep(0.2)
|
||||||
|
succeeded = await jobs.list_jobs(status=JobStatus.SUCCEEDED, limit=50)
|
||||||
queued = await jobs.list_jobs(status=JobStatus.QUEUED, limit=50)
|
queued = await jobs.list_jobs(status=JobStatus.QUEUED, limit=50)
|
||||||
finally:
|
finally:
|
||||||
await pool.stop()
|
await pool.stop()
|
||||||
|
|
||||||
assert {j.uri for j in succeeded} == {"g0", "g1", "g2"}
|
assert {j.uri for j in succeeded} == {"g0", "g1", "g2"}
|
||||||
assert {j.uri for j in queued} == {"b0", "b1", "b2"}
|
assert {j.uri for j in queued} == {"b0", "b1", "b2"}
|
||||||
assert [j.attempts for j in queued] == [0, 0, 0]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -18,18 +18,6 @@ def vcr_cassette_dir():
|
||||||
class TestSandboxBasics:
|
class TestSandboxBasics:
|
||||||
"""Test basic sandbox functionality."""
|
"""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
|
@pytest.mark.asyncio
|
||||||
async def test_execute_simple_code(self, sandbox):
|
async def test_execute_simple_code(self, sandbox):
|
||||||
"""Test executing simple code in the sandbox."""
|
"""Test executing simple code in the sandbox."""
|
||||||
|
|
@ -124,41 +112,6 @@ class TestSandboxListDocuments:
|
||||||
assert "Test Document" in result.stdout
|
assert "Test Document" in result.stdout
|
||||||
assert temp_db_path.stem 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:
|
class TestSandboxSearch:
|
||||||
"""Test search function in sandbox."""
|
"""Test search function in sandbox."""
|
||||||
|
|
@ -235,51 +188,6 @@ class TestSandboxSearch:
|
||||||
assert "str" in result.stdout
|
assert "str" in result.stdout
|
||||||
assert "True" 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:
|
class TestSandboxExternalFunctionEdgeCases:
|
||||||
"""Test edge cases in external function dispatch."""
|
"""Test edge cases in external function dispatch."""
|
||||||
|
|
@ -332,23 +240,6 @@ class TestSandboxExternalFunctionEdgeCases:
|
||||||
assert not result.success
|
assert not result.success
|
||||||
assert "external error" in result.stderr
|
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:
|
class TestSandboxOutputTruncation:
|
||||||
"""Test output truncation behavior."""
|
"""Test output truncation behavior."""
|
||||||
|
|
@ -421,14 +312,13 @@ class TestSandboxVFS:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.vcr()
|
@pytest.mark.vcr()
|
||||||
async def test_metadata_json(self, temp_db_path):
|
async def test_metadata_json(self, temp_db_path):
|
||||||
"""metadata.json contains document title, uri and stored metadata."""
|
"""metadata.json contains document title and uri."""
|
||||||
config = AppConfig()
|
config = AppConfig()
|
||||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||||
doc = await client.create_document(
|
doc = await client.create_document(
|
||||||
content="Test content",
|
content="Test content",
|
||||||
uri="test://doc1",
|
uri="test://doc1",
|
||||||
title="Test Document",
|
title="Test Document",
|
||||||
metadata={"author": "Ada"},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
context = AnalysisContext()
|
context = AnalysisContext()
|
||||||
|
|
@ -438,13 +328,11 @@ class TestSandboxVFS:
|
||||||
"import json\n"
|
"import json\n"
|
||||||
f"meta = json.loads(Path('/documents/{doc.id}/metadata.json').read_text())\n"
|
f"meta = json.loads(Path('/documents/{doc.id}/metadata.json').read_text())\n"
|
||||||
"print(meta['title'])\n"
|
"print(meta['title'])\n"
|
||||||
"print(meta['uri'])\n"
|
"print(meta['uri'])"
|
||||||
"print(meta['metadata']['author'])"
|
|
||||||
)
|
)
|
||||||
assert result.success, result.stderr
|
assert result.success
|
||||||
assert "Test Document" in result.stdout
|
assert "Test Document" in result.stdout
|
||||||
assert "test://doc1" in result.stdout
|
assert "test://doc1" in result.stdout
|
||||||
assert "Ada" in result.stdout
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.vcr()
|
@pytest.mark.vcr()
|
||||||
|
|
@ -498,59 +386,6 @@ class TestSandboxVFS:
|
||||||
assert result.success
|
assert result.success
|
||||||
assert result.stdout.count("True") == 6
|
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.asyncio
|
||||||
@pytest.mark.vcr()
|
@pytest.mark.vcr()
|
||||||
async def test_open_read(self, temp_db_path):
|
async def test_open_read(self, temp_db_path):
|
||||||
|
|
@ -598,8 +433,7 @@ class TestSandboxVFS:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"filename",
|
"filename", ["content.txt", "items.jsonl", "toc.json", "metadata.json"]
|
||||||
["content.txt", "items.jsonl", "chunks.jsonl", "toc.json", "metadata.json"],
|
|
||||||
)
|
)
|
||||||
async def test_write_denied_for_every_document_file(self, temp_db_path, filename):
|
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."""
|
"""Every file in the document VFS is read-only, metadata.json included."""
|
||||||
|
|
@ -963,61 +797,6 @@ class TestSandboxReadDeadline:
|
||||||
cannot check its duration budget while one is in flight. The sandbox
|
cannot check its duration budget while one is in flight. The sandbox
|
||||||
enforces the budget itself, before each read."""
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_read_after_deadline_raises_without_scheduling(self, sandbox):
|
async def test_read_after_deadline_raises_without_scheduling(self, sandbox):
|
||||||
"""A read attempted past the deadline fails instead of querying."""
|
"""A read attempted past the deadline fails instead of querying."""
|
||||||
|
|
@ -1046,51 +825,7 @@ class TestSandboxReadDeadline:
|
||||||
|
|
||||||
sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext())
|
sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext())
|
||||||
|
|
||||||
limits = sb._session_limits()
|
assert sb._session_limits() == {"max_duration_secs": 15.0}
|
||||||
|
|
||||||
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
|
@pytest.mark.asyncio
|
||||||
async def test_refused_read_fails_the_execution(self, temp_db_path, monkeypatch):
|
async def test_refused_read_fails_the_execution(self, temp_db_path, monkeypatch):
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import pytest
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
from haiku.rag.config.models import AppConfig
|
from haiku.rag.config.models import AppConfig
|
||||||
from haiku.rag.sandbox import AnalysisContext, Sandbox
|
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 import Document
|
||||||
from haiku.rag.store.models.document_item import DocumentItem
|
from haiku.rag.store.models.document_item import DocumentItem
|
||||||
|
|
||||||
|
|
@ -435,66 +434,6 @@ class TestVfsReadPaths:
|
||||||
"nope",
|
"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):
|
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 need not be contiguous — a heading's span may cover
|
||||||
positions that carry no item."""
|
positions that carry no item."""
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ def client():
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def app(tmp_path, client, monkeypatch):
|
def app(tmp_path, client, monkeypatch):
|
||||||
class StubHaikuRAG:
|
class StubHaikuRAG:
|
||||||
|
# run_mcp passes db_path positionally; every other caller uses kwargs.
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _covering(cls, *args, **kwargs):
|
def _covering(cls, *args, **kwargs):
|
||||||
return cls()
|
return cls()
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
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,37 +264,6 @@ def test_search_result_format_for_agent_omits_chunk_meta():
|
||||||
assert "para_no" not in formatted
|
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():
|
def test_search_result_format_for_agent_omits_document_meta():
|
||||||
"""Document metadata is UI plumbing, never shown to the model."""
|
"""Document metadata is UI plumbing, never shown to the model."""
|
||||||
result = SearchResult(
|
result = SearchResult(
|
||||||
|
|
@ -445,17 +414,6 @@ def test_search_result_format_for_agent_source_line(fields, expected_source):
|
||||||
assert expected_source in result.format_for_agent()
|
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(
|
@pytest.mark.parametrize(
|
||||||
"labels,expected",
|
"labels,expected",
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -1050,21 +1050,6 @@ def test_mcp_without_stdio_leaves_the_transport_unset(app_stub):
|
||||||
assert app_stub.run_mcp.call_args.kwargs["transport"] is None
|
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():
|
def test_version_flag_prints_the_version():
|
||||||
result = runner.invoke(cli, ["--version"])
|
result = runner.invoke(cli, ["--version"])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,7 @@ from docling_core.types.doc.document import DoclingDocument
|
||||||
from haiku.rag.config import AppConfig
|
from haiku.rag.config import AppConfig
|
||||||
from haiku.rag.config.models import ModelConfig
|
from haiku.rag.config.models import ModelConfig
|
||||||
from haiku.rag.converters import docling_local, get_converter
|
from haiku.rag.converters import docling_local, get_converter
|
||||||
from haiku.rag.converters.base import (
|
from haiku.rag.converters.base import vlm_api_headers, vlm_api_url
|
||||||
vlm_api_headers,
|
|
||||||
vlm_api_params,
|
|
||||||
vlm_api_url,
|
|
||||||
)
|
|
||||||
from haiku.rag.converters.docling_local import DoclingLocalConverter
|
from haiku.rag.converters.docling_local import DoclingLocalConverter
|
||||||
from haiku.rag.converters.docling_serve import DoclingServeConverter
|
from haiku.rag.converters.docling_serve import DoclingServeConverter
|
||||||
from haiku.rag.converters.text_utils import TextFileHandler, docling_safe_name
|
from haiku.rag.converters.text_utils import TextFileHandler, docling_safe_name
|
||||||
|
|
@ -54,32 +50,6 @@ class TestVlmApiUrl:
|
||||||
vlm_api_url(AppConfig(), ModelConfig(provider="unsupported", name="test"))
|
vlm_api_url(AppConfig(), ModelConfig(provider="unsupported", name="test"))
|
||||||
|
|
||||||
|
|
||||||
class TestVlmApiParams:
|
|
||||||
"""Request body docling posts alongside the picture."""
|
|
||||||
|
|
||||||
def test_thinking_unset_sends_no_effort(self):
|
|
||||||
params = vlm_api_params(ModelConfig(provider="ollama", name="qwen3.8"), 200)
|
|
||||||
assert params == {"model": "qwen3.8", "max_completion_tokens": 200}
|
|
||||||
|
|
||||||
def test_thinking_off_sends_none(self):
|
|
||||||
params = vlm_api_params(
|
|
||||||
ModelConfig(provider="ollama", name="qwen3.8", enable_thinking=False), 200
|
|
||||||
)
|
|
||||||
assert params["reasoning_effort"] == "none"
|
|
||||||
|
|
||||||
def test_thinking_off_sends_gpt_oss_floor(self):
|
|
||||||
params = vlm_api_params(
|
|
||||||
ModelConfig(provider="ollama", name="gpt-oss", enable_thinking=False), 200
|
|
||||||
)
|
|
||||||
assert params["reasoning_effort"] == "low"
|
|
||||||
|
|
||||||
def test_thinking_on_sends_high(self):
|
|
||||||
params = vlm_api_params(
|
|
||||||
ModelConfig(provider="ollama", name="qwen3.8", enable_thinking=True), 200
|
|
||||||
)
|
|
||||||
assert params["reasoning_effort"] == "high"
|
|
||||||
|
|
||||||
|
|
||||||
class TestVlmApiHeaders:
|
class TestVlmApiHeaders:
|
||||||
"""Auth headers for picture-description VLM models."""
|
"""Auth headers for picture-description VLM models."""
|
||||||
|
|
||||||
|
|
@ -878,7 +848,7 @@ class TestDoclingLocalConverter:
|
||||||
"""
|
"""
|
||||||
from haiku.rag.converters.pdf_split import convert_pdf_with_splitting
|
from haiku.rag.converters.pdf_split import convert_pdf_with_splitting
|
||||||
|
|
||||||
pdf_path = Path(__file__).parent / "data" / "doclaynet.pdf"
|
pdf_path = Path("tests/data/doclaynet.pdf")
|
||||||
config.processing.conversion_options.do_ocr = False
|
config.processing.conversion_options.do_ocr = False
|
||||||
converter = DoclingLocalConverter(config)
|
converter = DoclingLocalConverter(config)
|
||||||
|
|
||||||
|
|
@ -1019,7 +989,7 @@ class TestDoclingLocalConverter:
|
||||||
pic_desc = config.processing.conversion_options.picture_description
|
pic_desc = config.processing.conversion_options.picture_description
|
||||||
assert config.processing.pictures == "image"
|
assert config.processing.pictures == "image"
|
||||||
assert pic_desc.model.provider == "ollama"
|
assert pic_desc.model.provider == "ollama"
|
||||||
assert pic_desc.model.name == "qwen3.8"
|
assert pic_desc.model.name == "ministral-3"
|
||||||
assert pic_desc.timeout == 90
|
assert pic_desc.timeout == 90
|
||||||
assert pic_desc.max_tokens == 200
|
assert pic_desc.max_tokens == 200
|
||||||
# Default prompt is in PromptsConfig
|
# Default prompt is in PromptsConfig
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ def _stub_provider_probe(monkeypatch):
|
||||||
{
|
{
|
||||||
"models": [
|
"models": [
|
||||||
{"name": "test"},
|
{"name": "test"},
|
||||||
{"name": "qwen3.8:latest"},
|
{"name": "gpt-oss:latest"},
|
||||||
{"name": "qwen3-embedding:4b"},
|
{"name": "qwen3-embedding:4b"},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -751,11 +751,14 @@ def test_api_key_not_required_when_config_supplies_it():
|
||||||
|
|
||||||
|
|
||||||
def test_active_models_includes_picture_description_when_enabled():
|
def test_active_models_includes_picture_description_when_enabled():
|
||||||
base = _active_models(AppConfig())
|
config = AppConfig(processing=ProcessingConfig(pictures="description"))
|
||||||
with_pictures = _active_models(
|
names = [model.name for model in _active_models(config)]
|
||||||
AppConfig(processing=ProcessingConfig(pictures="description"))
|
assert "ministral-3" in names
|
||||||
)
|
|
||||||
assert len(with_pictures) == len(base) + 1
|
|
||||||
|
def test_active_models_excludes_picture_description_by_default():
|
||||||
|
names = [model.name for model in _active_models(AppConfig())]
|
||||||
|
assert "ministral-3" not in names
|
||||||
|
|
||||||
|
|
||||||
def test_active_models_includes_title_model_when_auto_title():
|
def test_active_models_includes_title_model_when_auto_title():
|
||||||
|
|
@ -843,7 +846,7 @@ def test_provider_targets_default_groups_ollama_models():
|
||||||
assert len(targets) == 1
|
assert len(targets) == 1
|
||||||
entry = next(iter(targets.values()))
|
entry = next(iter(targets.values()))
|
||||||
assert entry["kind"] == "ollama"
|
assert entry["kind"] == "ollama"
|
||||||
assert {"qwen3-embedding:4b", "qwen3.8"} <= entry["models"]
|
assert {"qwen3-embedding:4b", "gpt-oss"} <= entry["models"]
|
||||||
|
|
||||||
|
|
||||||
def test_provider_targets_includes_docling_serve():
|
def test_provider_targets_includes_docling_serve():
|
||||||
|
|
@ -919,7 +922,7 @@ async def test_provider_check_ok_when_models_present(monkeypatch):
|
||||||
{
|
{
|
||||||
"models": [
|
"models": [
|
||||||
{"name": "qwen3-embedding:4b"},
|
{"name": "qwen3-embedding:4b"},
|
||||||
{"name": "qwen3.8:latest"},
|
{"name": "gpt-oss:latest"},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -957,7 +960,7 @@ async def test_provider_check_fails_when_unreachable(monkeypatch):
|
||||||
async def test_provider_check_reports_local_provider(monkeypatch):
|
async def test_provider_check_reports_local_provider(monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"haiku.rag.doctor._probe_endpoint",
|
"haiku.rag.doctor._probe_endpoint",
|
||||||
_fake_probe((True, None, {"models": [{"name": "qwen3.8:latest"}]})),
|
_fake_probe((True, None, {"models": [{"name": "gpt-oss:latest"}]})),
|
||||||
)
|
)
|
||||||
config = AppConfig(
|
config = AppConfig(
|
||||||
embeddings=EmbeddingsConfig(
|
embeddings=EmbeddingsConfig(
|
||||||
|
|
@ -978,7 +981,7 @@ async def test_run_doctor_includes_provider_results(temp_db_path, monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"haiku.rag.doctor._probe_endpoint",
|
"haiku.rag.doctor._probe_endpoint",
|
||||||
_fake_probe(
|
_fake_probe(
|
||||||
(True, None, {"models": [{"name": "test"}, {"name": "qwen3.8:latest"}]})
|
(True, None, {"models": [{"name": "test"}, {"name": "gpt-oss:latest"}]})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
report = await run_doctor(_config(), temp_db_path, {})
|
report = await run_doctor(_config(), temp_db_path, {})
|
||||||
|
|
|
||||||
|
|
@ -77,8 +77,8 @@ async def test_download_models_ollama_pulls_models(mock_to_thread):
|
||||||
async for progress in download_models(get_config()):
|
async for progress in download_models(get_config()):
|
||||||
events.append(progress)
|
events.append(progress)
|
||||||
|
|
||||||
# Default config has embeddings=qwen3-embedding:4b, qa=qwen3.8
|
# Default config has embeddings=qwen3-embedding:4b, qa=gpt-oss
|
||||||
ollama_models = {"qwen3.8", "qwen3-embedding:4b"}
|
ollama_models = {"gpt-oss", "qwen3-embedding:4b"}
|
||||||
ollama_events = [e for e in events if e.model in ollama_models]
|
ollama_events = [e for e in events if e.model in ollama_models]
|
||||||
pulling_events = [e for e in ollama_events if e.status == "pulling"]
|
pulling_events = [e for e in ollama_events if e.status == "pulling"]
|
||||||
done_events = [e for e in ollama_events if e.status == "done"]
|
done_events = [e for e in ollama_events if e.status == "done"]
|
||||||
|
|
@ -108,7 +108,7 @@ async def test_download_models_no_ollama_models(mock_to_thread):
|
||||||
|
|
||||||
models = {e.model for e in events}
|
models = {e.model for e in events}
|
||||||
assert "qwen3-embedding:4b" not in models
|
assert "qwen3-embedding:4b" not in models
|
||||||
assert "qwen3.8" not in models
|
assert "gpt-oss" not in models
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|
|
||||||
1351
tests/test_mcp.py
1351
tests/test_mcp.py
File diff suppressed because it is too large
Load diff
|
|
@ -837,7 +837,7 @@ async def test_ingest_emits_picture_chunks_with_multimodal_embedder(
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_search_tool_skips_binary_content_when_qa_model_is_text_only():
|
async def test_search_tool_skips_binary_content_when_qa_model_is_text_only():
|
||||||
"""The agent search tool must NOT attach picture bytes when the QA model
|
"""The agent search tool must NOT attach picture bytes when the QA model
|
||||||
is text-only (``qa.model.vision = False``). Sending image
|
is text-only (``qa.model.vision = False``, the default). Sending image
|
||||||
parts to a text-only model would cause it to hallucinate confidently —
|
parts to a text-only model would cause it to hallucinate confidently —
|
||||||
Ollama silently accepts the bytes and the model guesses."""
|
Ollama silently accepts the bytes and the model guesses."""
|
||||||
|
|
||||||
|
|
@ -856,7 +856,8 @@ async def test_search_tool_skips_binary_content_when_qa_model_is_text_only():
|
||||||
fake_client.expand_context = AsyncMock(return_value=[picture_result])
|
fake_client.expand_context = AsyncMock(return_value=[picture_result])
|
||||||
|
|
||||||
config = AppConfig()
|
config = AppConfig()
|
||||||
config.qa.model.vision = False
|
# vision defaults to False; assert anyway so the test reads explicitly.
|
||||||
|
assert config.qa.model.vision is False
|
||||||
toolset = create_search_toolset(config, expand_context=False)
|
toolset = create_search_toolset(config, expand_context=False)
|
||||||
func = toolset.tools["search"].function
|
func = toolset.tools["search"].function
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -154,14 +154,6 @@ Emoji test: 🚀 ✅ 📝"""
|
||||||
{"provider": "ollama", "name": "gpt-oss", "enable_thinking": True},
|
{"provider": "ollama", "name": "gpt-oss", "enable_thinking": True},
|
||||||
{"openai_reasoning_effort": "high"},
|
{"openai_reasoning_effort": "high"},
|
||||||
),
|
),
|
||||||
(
|
|
||||||
{"provider": "ollama", "name": "qwen3.8", "enable_thinking": False},
|
|
||||||
{"openai_reasoning_effort": "none"},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
{"provider": "ollama", "name": "qwen3.8", "enable_thinking": True},
|
|
||||||
{"openai_reasoning_effort": "high"},
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
"provider": "ollama",
|
"provider": "ollama",
|
||||||
|
|
@ -196,8 +188,6 @@ Emoji test: 🚀 ✅ 📝"""
|
||||||
"ollama",
|
"ollama",
|
||||||
"ollama_thinking_off",
|
"ollama_thinking_off",
|
||||||
"ollama_thinking_on",
|
"ollama_thinking_on",
|
||||||
"ollama_other_thinking_off",
|
|
||||||
"ollama_other_thinking_on",
|
|
||||||
"ollama_with_settings",
|
"ollama_with_settings",
|
||||||
"openai",
|
"openai",
|
||||||
"openai_reasoning_thinking_on",
|
"openai_reasoning_thinking_on",
|
||||||
|
|
@ -662,6 +652,117 @@ def test_format_bytes():
|
||||||
assert format_bytes(1125899906842624) == "1.0 PB"
|
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 ---
|
# --- format_citations_rich tests ---
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -702,22 +803,6 @@ async def test_format_citations_rich_header_and_footer():
|
||||||
assert "chunk: chunk-uuid-1" in output
|
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():
|
async def test_format_citations_rich_names_the_database_when_federating():
|
||||||
"""Across databases, a citation has to say which one it came from."""
|
"""Across databases, a citation has to say which one it came from."""
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
|
||||||
204
uv.lock
204
uv.lock
|
|
@ -1209,22 +1209,21 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastmcp"
|
name = "fastmcp"
|
||||||
version = "4.0.2"
|
version = "3.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastmcp-slim", extra = ["client", "server"] },
|
{ name = "fastmcp-slim", extra = ["client", "server"] },
|
||||||
]
|
]
|
||||||
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" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastmcp-slim"
|
name = "fastmcp-slim"
|
||||||
version = "4.0.2"
|
version = "3.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "mcp-types" },
|
|
||||||
{ name = "platformdirs" },
|
{ name = "platformdirs" },
|
||||||
{ name = "pydantic", extra = ["email"] },
|
{ name = "pydantic", extra = ["email"] },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
|
|
@ -1232,28 +1231,26 @@ dependencies = [
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
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" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
client = [
|
client = [
|
||||||
{ name = "authlib" },
|
{ name = "authlib" },
|
||||||
{ name = "exceptiongroup" },
|
{ name = "exceptiongroup" },
|
||||||
{ name = "httpx2" },
|
{ name = "httpx" },
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
{ name = "opentelemetry-api" },
|
{ name = "opentelemetry-api" },
|
||||||
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
|
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
|
||||||
{ name = "starlette" },
|
|
||||||
]
|
]
|
||||||
server = [
|
server = [
|
||||||
{ name = "authlib" },
|
{ name = "authlib" },
|
||||||
{ name = "cyclopts" },
|
{ name = "cyclopts" },
|
||||||
{ name = "exceptiongroup" },
|
{ name = "exceptiongroup" },
|
||||||
{ name = "griffelib" },
|
{ name = "griffelib" },
|
||||||
{ name = "httpx2" },
|
{ name = "httpx" },
|
||||||
{ name = "joserfc" },
|
|
||||||
{ name = "jsonref" },
|
{ name = "jsonref" },
|
||||||
{ name = "jsonschema-path" },
|
{ name = "jsonschema-path" },
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
|
|
@ -1264,7 +1261,6 @@ server = [
|
||||||
{ name = "pyperclip" },
|
{ name = "pyperclip" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "starlette" },
|
|
||||||
{ name = "uncalled-for" },
|
{ name = "uncalled-for" },
|
||||||
{ name = "uvicorn" },
|
{ name = "uvicorn" },
|
||||||
{ name = "watchfiles" },
|
{ name = "watchfiles" },
|
||||||
|
|
@ -1759,7 +1755,7 @@ requires-dist = [
|
||||||
{ name = "docling", marker = "extra == 'docling'", specifier = ">=2.102.2,<3.0.0" },
|
{ name = "docling", marker = "extra == 'docling'", specifier = ">=2.102.2,<3.0.0" },
|
||||||
{ name = "docling-core", specifier = ">=2.82.0,<3.0.0" },
|
{ name = "docling-core", specifier = ">=2.82.0,<3.0.0" },
|
||||||
{ name = "fastapi", marker = "extra == 'ingester'", specifier = ">=0.125" },
|
{ name = "fastapi", marker = "extra == 'ingester'", specifier = ">=0.125" },
|
||||||
{ name = "fastmcp", specifier = ">=4.0.2,<5.0.0" },
|
{ name = "fastmcp", specifier = ">=3.3.0" },
|
||||||
{ name = "haiku-rag-slim", extras = ["s3"], marker = "extra == 'ingester'", editable = "haiku_rag_slim" },
|
{ name = "haiku-rag-slim", extras = ["s3"], marker = "extra == 'ingester'", editable = "haiku_rag_slim" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "jinja2", specifier = ">=3.1.0" },
|
{ name = "jinja2", specifier = ">=3.1.0" },
|
||||||
|
|
@ -1776,7 +1772,7 @@ requires-dist = [
|
||||||
{ name = "pydantic-ai-slim", extras = ["mistral"], marker = "extra == 'mistral'" },
|
{ 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 = ["openai", "logfire", "ag-ui"], specifier = ">=2.18.0,<3.0.0" },
|
||||||
{ name = "pydantic-ai-slim", extras = ["voyageai"], marker = "extra == 'voyageai'" },
|
{ name = "pydantic-ai-slim", extras = ["voyageai"], marker = "extra == 'voyageai'" },
|
||||||
{ name = "pydantic-monty", specifier = ">=0.0.23" },
|
{ name = "pydantic-monty", specifier = ">=0.0.19" },
|
||||||
{ name = "pypdfium2", specifier = ">=5.0" },
|
{ name = "pypdfium2", specifier = ">=5.0" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||||
|
|
@ -1891,6 +1887,15 @@ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "httpx2"
|
name = "httpx2"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
|
|
@ -2585,15 +2590,15 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcp"
|
name = "mcp"
|
||||||
version = "2.1.1"
|
version = "1.28.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
{ name = "httpx2" },
|
{ name = "httpx" },
|
||||||
|
{ name = "httpx-sse" },
|
||||||
{ name = "jsonschema" },
|
{ name = "jsonschema" },
|
||||||
{ name = "mcp-types" },
|
|
||||||
{ name = "opentelemetry-api" },
|
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
|
{ name = "pydantic-settings" },
|
||||||
{ name = "pyjwt", extra = ["crypto"] },
|
{ name = "pyjwt", extra = ["crypto"] },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||||
|
|
@ -2603,22 +2608,9 @@ dependencies = [
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||||
]
|
]
|
||||||
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" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ 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" },
|
||||||
]
|
|
||||||
|
|
||||||
[[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]]
|
[[package]]
|
||||||
|
|
@ -3991,82 +3983,94 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic-monty"
|
name = "pydantic-monty"
|
||||||
version = "0.0.23"
|
version = "0.0.19"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pydantic-monty-client" },
|
|
||||||
{ name = "pydantic-monty-runtime" },
|
{ 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" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
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" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic-monty-runtime"
|
name = "pydantic-monty-runtime"
|
||||||
version = "0.0.23"
|
version = "0.0.19"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
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" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -5539,11 +5543,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uncalled-for"
|
name = "uncalled-for"
|
||||||
version = "0.4.0"
|
version = "0.2.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
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" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue